diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index ef2236d0f2..a3e32c9797 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [
+ ".reference",
".plans",
"dist",
"dist-electron",
diff --git a/apps/server/package.json b/apps/server/package.json
index 930eff7b23..63e2034a8d 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -8,16 +8,16 @@
"directory": "apps/server"
},
"bin": {
- "t3": "./dist/index.mjs"
+ "t3": "./dist/bin.mjs"
},
"files": [
"dist"
],
"type": "module",
"scripts": {
- "dev": "bun run src/index.ts",
+ "dev": "bun run src/bin.ts",
"build": "node scripts/cli.ts build",
- "start": "node dist/index.mjs",
+ "start": "node dist/bin.mjs",
"prepare": "effect-language-service patch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
@@ -34,6 +34,7 @@
},
"devDependencies": {
"@effect/language-service": "catalog:",
+ "@effect/platform-bun": "catalog:",
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts
new file mode 100644
index 0000000000..55bf4d04b1
--- /dev/null
+++ b/apps/server/src/bin.ts
@@ -0,0 +1,14 @@
+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import { Command } from "effect/unstable/cli";
+
+import { NetService } from "@t3tools/shared/Net";
+import { cli } from "./cli";
+import { version } from "../package.json" with { type: "json" };
+
+Command.run(cli, { version }).pipe(
+ Effect.provide(Layer.mergeAll(NetService.layer, NodeServices.layer)),
+ NodeRuntime.runMain,
+);
diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts
new file mode 100644
index 0000000000..e91770db6c
--- /dev/null
+++ b/apps/server/src/cli-config.test.ts
@@ -0,0 +1,131 @@
+import os from "node:os";
+
+import { expect, it } from "@effect/vitest";
+import { ConfigProvider, Effect, Layer, Option, Path } from "effect";
+
+import { NetService } from "@t3tools/shared/Net";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { deriveServerPaths } from "./config";
+import { resolveServerConfig } from "./cli";
+
+it.layer(NodeServices.layer)("cli config resolution", (it) => {
+ it.effect("falls back to effect/config values when flags are omitted", () =>
+ Effect.gen(function* () {
+ const { join } = yield* Path.Path;
+ const baseDir = join(os.tmpdir(), "t3-cli-config-env-base");
+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.none(),
+ port: Option.none(),
+ host: Option.none(),
+ baseDir: Option.none(),
+ devUrl: Option.none(),
+ noBrowser: Option.none(),
+ authToken: Option.none(),
+ autoBootstrapProjectFromCwd: Option.none(),
+ logWebSocketEvents: Option.none(),
+ },
+ Option.none(),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ T3CODE_LOG_LEVEL: "Warn",
+ T3CODE_MODE: "desktop",
+ T3CODE_PORT: "4001",
+ T3CODE_HOST: "0.0.0.0",
+ T3CODE_HOME: baseDir,
+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
+ T3CODE_NO_BROWSER: "true",
+ T3CODE_AUTH_TOKEN: "env-token",
+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
+ T3CODE_LOG_WS_EVENTS: "true",
+ },
+ }),
+ ),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ expect(resolved).toEqual({
+ logLevel: "Warn",
+ mode: "desktop",
+ port: 4001,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host: "0.0.0.0",
+ staticDir: undefined,
+ devUrl: new URL("http://127.0.0.1:5173"),
+ noBrowser: true,
+ authToken: "env-token",
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: true,
+ });
+ }),
+ );
+
+ it.effect("uses CLI flags when provided", () =>
+ Effect.gen(function* () {
+ const { join } = yield* Path.Path;
+ const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base");
+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.some("web"),
+ port: Option.some(8788),
+ host: Option.some("127.0.0.1"),
+ baseDir: Option.some(baseDir),
+ devUrl: Option.some(new URL("http://127.0.0.1:4173")),
+ noBrowser: Option.some(true),
+ authToken: Option.some("flag-token"),
+ autoBootstrapProjectFromCwd: Option.some(true),
+ logWebSocketEvents: Option.some(true),
+ },
+ Option.some("Debug"),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ T3CODE_LOG_LEVEL: "Warn",
+ T3CODE_MODE: "desktop",
+ T3CODE_PORT: "4001",
+ T3CODE_HOST: "0.0.0.0",
+ T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
+ T3CODE_NO_BROWSER: "false",
+ T3CODE_AUTH_TOKEN: "ignored-token",
+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
+ T3CODE_LOG_WS_EVENTS: "false",
+ },
+ }),
+ ),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ expect(resolved).toEqual({
+ logLevel: "Debug",
+ mode: "web",
+ port: 8788,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host: "127.0.0.1",
+ staticDir: undefined,
+ devUrl: new URL("http://127.0.0.1:4173"),
+ noBrowser: true,
+ authToken: "flag-token",
+ autoBootstrapProjectFromCwd: true,
+ logWebSocketEvents: true,
+ });
+ }),
+ );
+});
diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts
new file mode 100644
index 0000000000..bc44ab16a5
--- /dev/null
+++ b/apps/server/src/cli.test.ts
@@ -0,0 +1,37 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { NetService } from "@t3tools/shared/Net";
+import { assert, it } from "@effect/vitest";
+import { Effect, Layer } from "effect";
+import * as CliError from "effect/unstable/cli/CliError";
+import { Command } from "effect/unstable/cli";
+
+import { cli } from "./cli.ts";
+
+const provideCliRuntime = (effect: Effect.Effect) =>
+ effect.pipe(Effect.provide(Layer.mergeAll(NetService.layer, NodeServices.layer)));
+
+it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
+ it.effect("accepts the built-in lowercase log-level flag values", () =>
+ Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe(
+ provideCliRuntime,
+ ),
+ );
+
+ it.effect("rejects invalid log-level casing before launching the server", () =>
+ Effect.gen(function* () {
+ const error = yield* Command.runWith(cli, { version: "0.0.0" })([
+ "--log-level",
+ "Debug",
+ ]).pipe(provideCliRuntime, Effect.flip);
+
+ if (!CliError.isCliError(error)) {
+ throw new Error(`Expected CliError, got ${String(error)}`);
+ }
+ if (error._tag !== "InvalidValue") {
+ throw new Error(`Expected InvalidValue, got ${error._tag}`);
+ }
+ assert.equal(error.option, "log-level");
+ assert.equal(error.value, "Debug");
+ }),
+ );
+});
diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts
new file mode 100644
index 0000000000..0c2b3fbb29
--- /dev/null
+++ b/apps/server/src/cli.ts
@@ -0,0 +1,198 @@
+import { NetService } from "@t3tools/shared/Net";
+import { Config, Effect, LogLevel, Option, Schema } from "effect";
+import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
+
+import {
+ DEFAULT_PORT,
+ deriveServerPaths,
+ resolveStaticDir,
+ ServerConfig,
+ type RuntimeMode,
+ type ServerConfigShape,
+} from "./config";
+import { resolveBaseDir } from "./os-jank";
+import { runServer } from "./server";
+
+const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
+ Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
+ Flag.optional,
+);
+const portFlag = Flag.integer("port").pipe(
+ Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
+ Flag.withDescription("Port for the HTTP/WebSocket server."),
+ Flag.optional,
+);
+const hostFlag = Flag.string("host").pipe(
+ Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
+ Flag.optional,
+);
+const baseDirFlag = Flag.string("base-dir").pipe(
+ Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),
+ Flag.optional,
+);
+const devUrlFlag = Flag.string("dev-url").pipe(
+ Flag.withSchema(Schema.URLFromString),
+ Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
+ Flag.optional,
+);
+const noBrowserFlag = Flag.boolean("no-browser").pipe(
+ Flag.withDescription("Disable automatic browser opening."),
+ Flag.optional,
+);
+const authTokenFlag = Flag.string("auth-token").pipe(
+ Flag.withDescription("Auth token required for WebSocket connections."),
+ Flag.withAlias("token"),
+ Flag.optional,
+);
+const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
+ Flag.withDescription(
+ "Create a project for the current working directory on startup when missing.",
+ ),
+ Flag.optional,
+);
+const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
+ Flag.withDescription(
+ "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
+ ),
+ Flag.withAlias("log-ws-events"),
+ Flag.optional,
+);
+
+const EnvServerConfig = Config.all({
+ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
+ mode: Config.string("T3CODE_MODE").pipe(
+ Config.option,
+ Config.map(
+ Option.match({
+ onNone: () => "web",
+ onSome: (value) => (value === "desktop" ? "desktop" : "web"),
+ }),
+ ),
+ ),
+ port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+ logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
+});
+
+interface CliServerFlags {
+ readonly mode: Option.Option;
+ readonly port: Option.Option;
+ readonly host: Option.Option;
+ readonly baseDir: Option.Option;
+ readonly devUrl: Option.Option;
+ readonly noBrowser: Option.Option;
+ readonly authToken: Option.Option;
+ readonly autoBootstrapProjectFromCwd: Option.Option;
+ readonly logWebSocketEvents: Option.Option;
+}
+
+const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) =>
+ Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
+
+export const resolveServerConfig = (
+ flags: CliServerFlags,
+ cliLogLevel: Option.Option,
+) =>
+ Effect.gen(function* () {
+ const { findAvailablePort } = yield* NetService;
+ const env = yield* EnvServerConfig;
+
+ const mode = Option.getOrElse(flags.mode, () => env.mode);
+
+ const port = yield* Option.match(flags.port, {
+ onSome: (value) => Effect.succeed(value),
+ onNone: () => {
+ if (env.port) {
+ return Effect.succeed(env.port);
+ }
+ if (mode === "desktop") {
+ return Effect.succeed(DEFAULT_PORT);
+ }
+ return findAvailablePort(DEFAULT_PORT);
+ },
+ });
+ const devUrl = Option.getOrElse(flags.devUrl, () => env.devUrl);
+ const baseDir = yield* resolveBaseDir(Option.getOrUndefined(flags.baseDir) ?? env.t3Home);
+ const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
+ const noBrowser = resolveBooleanFlag(flags.noBrowser, env.noBrowser ?? mode === "desktop");
+ const authToken = Option.getOrUndefined(flags.authToken) ?? env.authToken;
+ const autoBootstrapProjectFromCwd = resolveBooleanFlag(
+ flags.autoBootstrapProjectFromCwd,
+ env.autoBootstrapProjectFromCwd ?? mode === "web",
+ );
+ const logWebSocketEvents = resolveBooleanFlag(
+ flags.logWebSocketEvents,
+ env.logWebSocketEvents ?? Boolean(devUrl),
+ );
+ const staticDir = devUrl ? undefined : yield* resolveStaticDir();
+ const host =
+ Option.getOrUndefined(flags.host) ??
+ env.host ??
+ (mode === "desktop" ? "127.0.0.1" : undefined);
+ const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
+
+ const config: ServerConfigShape = {
+ logLevel,
+ mode,
+ port,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ host,
+ staticDir,
+ devUrl,
+ noBrowser,
+ authToken,
+ autoBootstrapProjectFromCwd,
+ logWebSocketEvents,
+ };
+
+ return config;
+ });
+
+const commandFlags = {
+ mode: modeFlag,
+ port: portFlag,
+ host: hostFlag,
+ baseDir: baseDirFlag,
+ devUrl: devUrlFlag,
+ noBrowser: noBrowserFlag,
+ authToken: authTokenFlag,
+ autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
+ logWebSocketEvents: logWebSocketEventsFlag,
+} as const;
+
+const rootCommand = Command.make("t3", commandFlags).pipe(
+ Command.withDescription("Run the T3 Code server."),
+ Command.withHandler((flags) =>
+ Effect.gen(function* () {
+ const logLevel = yield* GlobalFlag.LogLevel;
+ const config = yield* resolveServerConfig(flags, logLevel);
+ return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
+ }),
+ ),
+);
+
+const resetCommand = Command.make("reset", {}).pipe(
+ Command.withDescription("Reset the T3 Code server."),
+ Command.withHandler(() => Effect.die("Not implemented")),
+);
+
+export const cli = rootCommand.pipe(Command.withSubcommands([resetCommand]));
diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
index 8553ce9667..2bfbf24c4b 100644
--- a/apps/server/src/config.ts
+++ b/apps/server/src/config.ts
@@ -6,7 +6,7 @@
*
* @module ServerConfig
*/
-import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
+import { Effect, FileSystem, Layer, LogLevel, Path, ServiceMap } from "effect";
export const DEFAULT_PORT = 3773;
@@ -33,6 +33,7 @@ export interface ServerDerivedPaths {
* ServerConfigShape - Process/runtime configuration required by the server.
*/
export interface ServerConfigShape extends ServerDerivedPaths {
+ readonly logLevel: LogLevel.LogLevel;
readonly mode: RuntimeMode;
readonly port: number;
readonly host: string | undefined;
@@ -95,6 +96,7 @@ export class ServerConfig extends ServiceMap.Service()("GitCommandError", {
- operation: Schema.String,
- command: Schema.String,
- cwd: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {
- override get message(): string {
- return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
- }
-}
-
-/**
- * GitHubCliError - GitHub CLI execution or authentication failed.
- */
-export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", {
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {
- override get message(): string {
- return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
- }
-}
-
-/**
- * TextGenerationError - Commit or PR text generation failed.
- */
-export class TextGenerationError extends Schema.TaggedErrorClass()(
- "TextGenerationError",
- {
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
- },
-) {
- override get message(): string {
- return `Text generation failed in ${this.operation}: ${this.detail}`;
- }
-}
-
-/**
- * GitManagerError - Stacked Git workflow orchestration failed.
- */
-export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", {
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {
- override get message(): string {
- return `Git manager failed in ${this.operation}: ${this.detail}`;
- }
-}
-
-/**
- * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
- */
-export type GitManagerServiceError =
- | GitManagerError
- | GitCommandError
- | GitHubCliError
- | TextGenerationError;
+export {
+ GitCommandError,
+ GitHubCliError,
+ GitManagerError,
+ TextGenerationError,
+ type GitManagerServiceError,
+} from "@t3tools/contracts";
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
new file mode 100644
index 0000000000..0f14924fd1
--- /dev/null
+++ b/apps/server/src/http.ts
@@ -0,0 +1,166 @@
+import Mime from "@effect/platform-node/Mime";
+import { Effect, FileSystem, Option, Path } from "effect";
+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
+
+import {
+ ATTACHMENTS_ROUTE_PREFIX,
+ normalizeAttachmentRelativePath,
+ resolveAttachmentRelativePath,
+} from "./attachmentPaths";
+import { resolveAttachmentPathById } from "./attachmentStore";
+import { ServerConfig } from "./config";
+
+const HEALTH_ROUTE_PATH = "/health";
+
+export const healthRouteLayer = HttpRouter.add(
+ "GET",
+ HEALTH_ROUTE_PATH,
+ HttpServerResponse.json({ ok: true }),
+);
+
+export const attachmentsRouteLayer = HttpRouter.add(
+ "GET",
+ `${ATTACHMENTS_ROUTE_PREFIX}/*`,
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ const url = HttpServerRequest.toURL(request);
+ if (Option.isNone(url)) {
+ return HttpServerResponse.text("Bad Request", { status: 400 });
+ }
+
+ const config = yield* ServerConfig;
+ const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
+ const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
+ if (!normalizedRelativePath) {
+ return HttpServerResponse.text("Invalid attachment path", { status: 400 });
+ }
+
+ const isIdLookup =
+ !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
+ const filePath = isIdLookup
+ ? resolveAttachmentPathById({
+ attachmentsDir: config.attachmentsDir,
+ attachmentId: normalizedRelativePath,
+ })
+ : resolveAttachmentRelativePath({
+ attachmentsDir: config.attachmentsDir,
+ relativePath: normalizedRelativePath,
+ });
+ if (!filePath) {
+ return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", {
+ status: isIdLookup ? 404 : 400,
+ });
+ }
+
+ const fileSystem = yield* FileSystem.FileSystem;
+ const fileInfo = yield* fileSystem
+ .stat(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!fileInfo || fileInfo.type !== "File") {
+ return HttpServerResponse.text("Not Found", { status: 404 });
+ }
+
+ const contentType = Mime.getType(filePath) ?? "application/octet-stream";
+ const data = yield* fileSystem
+ .readFile(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!data) {
+ return HttpServerResponse.text("Internal Server Error", { status: 500 });
+ }
+
+ return HttpServerResponse.uint8Array(data, {
+ status: 200,
+ contentType,
+ headers: {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+ }),
+);
+
+export const staticAndDevRouteLayer = HttpRouter.add(
+ "GET",
+ "*",
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ const url = HttpServerRequest.toURL(request);
+ if (Option.isNone(url)) {
+ return HttpServerResponse.text("Bad Request", { status: 400 });
+ }
+
+ const config = yield* ServerConfig;
+ if (config.devUrl) {
+ return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
+ }
+
+ if (!config.staticDir) {
+ return HttpServerResponse.text("No static directory configured and no dev URL set.", {
+ status: 503,
+ });
+ }
+
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const staticRoot = path.resolve(config.staticDir);
+ const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
+ const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
+ const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
+ const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
+ const hasPathTraversalSegment = staticRelativePath.startsWith("..");
+ if (
+ staticRelativePath.length === 0 ||
+ hasRawLeadingParentSegment ||
+ hasPathTraversalSegment ||
+ staticRelativePath.includes("\0")
+ ) {
+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
+ }
+
+ const isWithinStaticRoot = (candidate: string) =>
+ candidate === staticRoot ||
+ candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`);
+
+ let filePath = path.resolve(staticRoot, staticRelativePath);
+ if (!isWithinStaticRoot(filePath)) {
+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
+ }
+
+ const ext = path.extname(filePath);
+ if (!ext) {
+ filePath = path.resolve(filePath, "index.html");
+ if (!isWithinStaticRoot(filePath)) {
+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
+ }
+ }
+
+ const fileInfo = yield* fileSystem
+ .stat(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!fileInfo || fileInfo.type !== "File") {
+ const indexPath = path.resolve(staticRoot, "index.html");
+ const indexData = yield* fileSystem
+ .readFile(indexPath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!indexData) {
+ return HttpServerResponse.text("Not Found", { status: 404 });
+ }
+ return HttpServerResponse.uint8Array(indexData, {
+ status: 200,
+ contentType: "text/html; charset=utf-8",
+ });
+ }
+
+ const contentType = Mime.getType(filePath) ?? "application/octet-stream";
+ const data = yield* fileSystem
+ .readFile(filePath)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!data) {
+ return HttpServerResponse.text("Internal Server Error", { status: 500 });
+ }
+
+ return HttpServerResponse.uint8Array(data, {
+ status: 200,
+ contentType,
+ });
+ }),
+);
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
deleted file mode 100644
index 363a07ee38..0000000000
--- a/apps/server/src/index.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-
-import { CliConfig, t3Cli } from "./main";
-import { OpenLive } from "./open";
-import { Command } from "effect/unstable/cli";
-import { version } from "../package.json" with { type: "json" };
-import { ServerLive } from "./wsServer";
-import { NetService } from "@t3tools/shared/Net";
-import { FetchHttpClient } from "effect/unstable/http";
-
-const RuntimeLayer = Layer.empty.pipe(
- Layer.provideMerge(CliConfig.layer),
- Layer.provideMerge(ServerLive),
- Layer.provideMerge(OpenLive),
- Layer.provideMerge(NetService.layer),
- Layer.provideMerge(NodeServices.layer),
- Layer.provideMerge(FetchHttpClient.layer),
-);
-
-Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts
index bf58467825..9d22089f4d 100644
--- a/apps/server/src/keybindings.ts
+++ b/apps/server/src/keybindings.ts
@@ -9,6 +9,7 @@
import {
KeybindingRule,
KeybindingsConfig,
+ KeybindingsConfigError,
KeybindingShortcut,
KeybindingWhenNode,
MAX_KEYBINDINGS_COUNT,
@@ -43,18 +44,7 @@ import {
import * as Semaphore from "effect/Semaphore";
import { ServerConfig } from "./config";
-export class KeybindingsConfigError extends Schema.TaggedErrorClass()(
- "KeybindingsConfigParseError",
- {
- configPath: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
- },
-) {
- override get message(): string {
- return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
- }
-}
+export { KeybindingsConfigError };
type WhenToken =
| { type: "identifier"; value: string }
diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts
deleted file mode 100644
index b1e5da0c87..0000000000
--- a/apps/server/src/main.test.ts
+++ /dev/null
@@ -1,300 +0,0 @@
-import * as Http from "node:http";
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import { assert, it, vi } from "@effect/vitest";
-import type { OrchestrationReadModel } from "@t3tools/contracts";
-import * as ConfigProvider from "effect/ConfigProvider";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-import * as Command from "effect/unstable/cli/Command";
-import { FetchHttpClient } from "effect/unstable/http";
-import { beforeEach } from "vitest";
-import { NetService } from "@t3tools/shared/Net";
-
-import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main";
-import { ServerConfig, type ServerConfigShape } from "./config";
-import { Open, type OpenShape } from "./open";
-import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
-import { Server, type ServerShape } from "./wsServer";
-
-const start = vi.fn(() => undefined);
-const stop = vi.fn(() => undefined);
-let resolvedConfig: ServerConfigShape | null = null;
-const serverStart = Effect.acquireRelease(
- Effect.gen(function* () {
- resolvedConfig = yield* ServerConfig;
- start();
- return {} as unknown as Http.Server;
- }),
- () => Effect.sync(() => stop()),
-);
-const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
-
-// Shared service layer used by this CLI test suite.
-const testLayer = Layer.mergeAll(
- Layer.succeed(CliConfig, {
- cwd: "/tmp/t3-test-workspace",
- fixPath: Effect.void,
- resolveStaticDir: Effect.undefined,
- } satisfies CliConfigShape),
- Layer.succeed(NetService, {
- canListenOnHost: () => Effect.succeed(true),
- isPortAvailableOnLoopback: () => Effect.succeed(true),
- reserveLoopbackPort: () => Effect.succeed(0),
- findAvailablePort,
- }),
- Layer.succeed(Server, {
- start: serverStart,
- stopSignal: Effect.void,
- } satisfies ServerShape),
- Layer.succeed(Open, {
- openBrowser: (_target: string) => Effect.void,
- openInEditor: () => Effect.void,
- } satisfies OpenShape),
- AnalyticsService.layerTest,
- FetchHttpClient.layer,
- NodeServices.layer,
-);
-
-const runCli = (
- args: ReadonlyArray,
- env: Record = { T3CODE_NO_BROWSER: "true" },
-) => {
- return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe(
- Effect.provide(
- ConfigProvider.layer(
- ConfigProvider.fromEnv({
- env: {
- ...env,
- },
- }),
- ),
- ),
- );
-};
-
-beforeEach(() => {
- vi.clearAllMocks();
- resolvedConfig = null;
- start.mockImplementation(() => undefined);
- stop.mockImplementation(() => undefined);
- findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
-});
-
-it.layer(testLayer)("server CLI command", (it) => {
- it.effect("parses all CLI flags and wires scoped start/stop", () =>
- Effect.gen(function* () {
- yield* runCli([
- "--mode",
- "desktop",
- "--port",
- "4010",
- "--host",
- "0.0.0.0",
- "--home-dir",
- "/tmp/t3-cli-home",
- "--dev-url",
- "http://127.0.0.1:5173",
- "--no-browser",
- "--auth-token",
- "auth-secret",
- ]);
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "desktop");
- assert.equal(resolvedConfig?.port, 4010);
- assert.equal(resolvedConfig?.host, "0.0.0.0");
- assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home");
- assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev");
- assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
- assert.equal(resolvedConfig?.noBrowser, true);
- assert.equal(resolvedConfig?.authToken, "auth-secret");
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
- assert.equal(resolvedConfig?.logWebSocketEvents, true);
- assert.equal(stop.mock.calls.length, 1);
- }),
- );
-
- it.effect("supports --token as an alias for --auth-token", () =>
- Effect.gen(function* () {
- yield* runCli(["--token", "token-secret"]);
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.authToken, "token-secret");
- }),
- );
-
- it.effect("uses env fallbacks when flags are not provided", () =>
- Effect.gen(function* () {
- yield* runCli([], {
- T3CODE_MODE: "desktop",
- T3CODE_PORT: "4999",
- T3CODE_HOST: "100.88.10.4",
- T3CODE_HOME: "/tmp/t3-env-home",
- VITE_DEV_SERVER_URL: "http://localhost:5173",
- T3CODE_NO_BROWSER: "true",
- T3CODE_AUTH_TOKEN: "env-token",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "desktop");
- assert.equal(resolvedConfig?.port, 4999);
- assert.equal(resolvedConfig?.host, "100.88.10.4");
- assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
- assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev");
- assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
- assert.equal(resolvedConfig?.noBrowser, true);
- assert.equal(resolvedConfig?.authToken, "env-token");
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
- assert.equal(resolvedConfig?.logWebSocketEvents, true);
- assert.equal(findAvailablePort.mock.calls.length, 0);
- }),
- );
-
- it.effect("prefers --mode over T3CODE_MODE", () =>
- Effect.gen(function* () {
- findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));
- yield* runCli(["--mode", "web"], {
- T3CODE_MODE: "desktop",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "web");
- assert.equal(resolvedConfig?.port, 4666);
- assert.equal(resolvedConfig?.host, undefined);
- }),
- );
-
- it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () =>
- Effect.gen(function* () {
- yield* runCli(["--no-browser"], {
- T3CODE_NO_BROWSER: "false",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.noBrowser, true);
- }),
- );
-
- it.effect("uses dynamic port discovery in web mode when port is omitted", () =>
- Effect.gen(function* () {
- findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444));
- yield* runCli([]);
-
- assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.port, 5444);
- assert.equal(resolvedConfig?.mode, "web");
- }),
- );
-
- it.effect("uses fixed localhost defaults in desktop mode", () =>
- Effect.gen(function* () {
- yield* runCli([], {
- T3CODE_MODE: "desktop",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.equal(findAvailablePort.mock.calls.length, 0);
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.port, 3773);
- assert.equal(resolvedConfig?.host, "127.0.0.1");
- assert.equal(resolvedConfig?.mode, "desktop");
- }),
- );
-
- it.effect("allows overriding desktop host with --host", () =>
- Effect.gen(function* () {
- yield* runCli(["--host", "0.0.0.0"], {
- T3CODE_MODE: "desktop",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.mode, "desktop");
- assert.equal(resolvedConfig?.host, "0.0.0.0");
- }),
- );
-
- it.effect("supports CLI and env for bootstrap/log websocket toggles", () =>
- Effect.gen(function* () {
- yield* runCli(["--auto-bootstrap-project-from-cwd"], {
- T3CODE_MODE: "desktop",
- T3CODE_LOG_WS_EVENTS: "false",
- T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
- T3CODE_NO_BROWSER: "true",
- });
-
- assert.equal(start.mock.calls.length, 1);
- assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
- assert.equal(resolvedConfig?.logWebSocketEvents, false);
- }),
- );
-
- it.effect("records a startup heartbeat with thread/project counts", () =>
- Effect.gen(function* () {
- const recordTelemetry = vi.fn(
- (_event: string, _properties?: Readonly>) => Effect.void,
- );
- const getSnapshot = vi.fn(() =>
- Effect.succeed({
- snapshotSequence: 2,
- projects: [{} as OrchestrationReadModel["projects"][number]],
- threads: [
- {} as OrchestrationReadModel["threads"][number],
- {} as OrchestrationReadModel["threads"][number],
- ],
- updatedAt: new Date(1).toISOString(),
- } satisfies OrchestrationReadModel),
- );
-
- yield* recordStartupHeartbeat.pipe(
- Effect.provideService(ProjectionSnapshotQuery, {
- getSnapshot,
- }),
- Effect.provideService(AnalyticsService, {
- record: recordTelemetry,
- flush: Effect.void,
- }),
- );
-
- assert.deepEqual(recordTelemetry.mock.calls[0], [
- "server.boot.heartbeat",
- {
- threadCount: 2,
- projectCount: 1,
- },
- ]);
- }),
- );
-
- it.effect("does not start server for invalid --mode values", () =>
- Effect.gen(function* () {
- yield* runCli(["--mode", "invalid"]);
-
- assert.equal(start.mock.calls.length, 0);
- assert.equal(stop.mock.calls.length, 0);
- }),
- );
-
- it.effect("does not start server for invalid --dev-url values", () =>
- Effect.gen(function* () {
- yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void));
-
- assert.equal(start.mock.calls.length, 0);
- assert.equal(stop.mock.calls.length, 0);
- }),
- );
-
- it.effect("does not start server for out-of-range --port values", () =>
- Effect.gen(function* () {
- yield* runCli(["--port", "70000"]);
-
- // effect/unstable/cli renders help/errors for parse failures and returns success.
- assert.equal(start.mock.calls.length, 0);
- assert.equal(stop.mock.calls.length, 0);
- }),
- );
-});
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
deleted file mode 100644
index 17bf7f32f7..0000000000
--- a/apps/server/src/main.ts
+++ /dev/null
@@ -1,343 +0,0 @@
-/**
- * CliConfig - CLI/runtime bootstrap service definitions.
- *
- * Defines startup-only service contracts used while resolving process config
- * and constructing server runtime layers.
- *
- * @module CliConfig
- */
-import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
-import { Command, Flag } from "effect/unstable/cli";
-import { NetService } from "@t3tools/shared/Net";
-import {
- DEFAULT_PORT,
- deriveServerPaths,
- resolveStaticDir,
- ServerConfig,
- type RuntimeMode,
- type ServerConfigShape,
-} from "./config";
-import { fixPath, resolveBaseDir } from "./os-jank";
-import { Open } from "./open";
-import * as SqlitePersistence from "./persistence/Layers/Sqlite";
-import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
-import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
-import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
-import { Server } from "./wsServer";
-import { ServerLoggerLive } from "./serverLogger";
-import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
-
-export class StartupError extends Data.TaggedError("StartupError")<{
- readonly message: string;
- readonly cause?: unknown;
-}> {}
-
-interface CliInput {
- readonly mode: Option.Option;
- readonly port: Option.Option;
- readonly host: Option.Option;
- readonly t3Home: Option.Option;
- readonly devUrl: Option.Option;
- readonly noBrowser: Option.Option;
- readonly authToken: Option.Option;
- readonly autoBootstrapProjectFromCwd: Option.Option;
- readonly logWebSocketEvents: Option.Option;
-}
-
-/**
- * CliConfigShape - Startup helpers required while building server layers.
- */
-export interface CliConfigShape {
- /**
- * Current process working directory.
- */
- readonly cwd: string;
-
- /**
- * Apply OS-specific PATH normalization.
- */
- readonly fixPath: Effect.Effect;
-
- /**
- * Resolve static web asset directory for server mode.
- */
- readonly resolveStaticDir: Effect.Effect;
-}
-
-/**
- * CliConfig - Service tag for startup CLI/runtime helpers.
- */
-export class CliConfig extends ServiceMap.Service()(
- "t3/main/CliConfig",
-) {
- static readonly layer = Layer.effect(
- CliConfig,
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- return {
- cwd: process.cwd(),
- fixPath: Effect.sync(fixPath),
- resolveStaticDir: resolveStaticDir().pipe(
- Effect.provideService(FileSystem.FileSystem, fileSystem),
- Effect.provideService(Path.Path, path),
- ),
- } satisfies CliConfigShape;
- }),
- );
-}
-
-const CliEnvConfig = Config.all({
- mode: Config.string("T3CODE_MODE").pipe(
- Config.option,
- Config.map(
- Option.match({
- onNone: () => "web",
- onSome: (value) => (value === "desktop" ? "desktop" : "web"),
- }),
- ),
- ),
- port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
- host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
- t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
- devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
- noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
- authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
- autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
- logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
- Config.option,
- Config.map(Option.getOrUndefined),
- ),
-});
-
-const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) =>
- Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
-
-const ServerConfigLive = (input: CliInput) =>
- Layer.effect(
- ServerConfig,
- Effect.gen(function* () {
- const cliConfig = yield* CliConfig;
- const { findAvailablePort } = yield* NetService;
- const env = yield* CliEnvConfig.asEffect().pipe(
- Effect.mapError(
- (cause) =>
- new StartupError({ message: "Failed to read environment configuration", cause }),
- ),
- );
-
- const mode = Option.getOrElse(input.mode, () => env.mode);
-
- const port = yield* Option.match(input.port, {
- onSome: (value) => Effect.succeed(value),
- onNone: () => {
- if (env.port) {
- return Effect.succeed(env.port);
- }
- if (mode === "desktop") {
- return Effect.succeed(DEFAULT_PORT);
- }
- return findAvailablePort(DEFAULT_PORT);
- },
- });
-
- const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);
- const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home);
- const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
- const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");
- const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
- const autoBootstrapProjectFromCwd = resolveBooleanFlag(
- input.autoBootstrapProjectFromCwd,
- env.autoBootstrapProjectFromCwd ?? mode === "web",
- );
- const logWebSocketEvents = resolveBooleanFlag(
- input.logWebSocketEvents,
- env.logWebSocketEvents ?? Boolean(devUrl),
- );
- const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
- const host =
- Option.getOrUndefined(input.host) ??
- env.host ??
- (mode === "desktop" ? "127.0.0.1" : undefined);
-
- const config: ServerConfigShape = {
- mode,
- port,
- cwd: cliConfig.cwd,
- host,
- baseDir,
- ...derivedPaths,
- staticDir,
- devUrl,
- noBrowser,
- authToken,
- autoBootstrapProjectFromCwd,
- logWebSocketEvents,
- } satisfies ServerConfigShape;
-
- return config;
- }),
- );
-
-const LayerLive = (input: CliInput) =>
- Layer.empty.pipe(
- Layer.provideMerge(makeServerRuntimeServicesLayer()),
- Layer.provideMerge(makeServerProviderLayer()),
- Layer.provideMerge(ProviderHealthLive),
- Layer.provideMerge(SqlitePersistence.layerConfig),
- Layer.provideMerge(ServerLoggerLive),
- Layer.provideMerge(AnalyticsServiceLayerLive),
- Layer.provideMerge(ServerConfigLive(input)),
- );
-
-const isWildcardHost = (host: string | undefined): boolean =>
- host === "0.0.0.0" || host === "::" || host === "[::]";
-
-const formatHostForUrl = (host: string): string =>
- host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
-
-export const recordStartupHeartbeat = Effect.gen(function* () {
- const analytics = yield* AnalyticsService;
- const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
-
- const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
- Effect.map((snapshot) => ({
- threadCount: snapshot.threads.length,
- projectCount: snapshot.projects.length,
- })),
- Effect.catch((cause) =>
- Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
- Effect.as({
- threadCount: 0,
- projectCount: 0,
- }),
- ),
- ),
- );
-
- yield* analytics.record("server.boot.heartbeat", {
- threadCount,
- projectCount,
- });
-});
-
-const makeServerProgram = (input: CliInput) =>
- Effect.gen(function* () {
- const cliConfig = yield* CliConfig;
- const { start, stopSignal } = yield* Server;
- const openDeps = yield* Open;
- yield* cliConfig.fixPath;
-
- const config = yield* ServerConfig;
-
- if (!config.devUrl && !config.staticDir) {
- yield* Effect.logWarning(
- "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable",
- {
- hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
- },
- );
- }
-
- yield* start;
- yield* Effect.forkChild(recordStartupHeartbeat);
-
- const localUrl = `http://localhost:${config.port}`;
- const bindUrl =
- config.host && !isWildcardHost(config.host)
- ? `http://${formatHostForUrl(config.host)}:${config.port}`
- : localUrl;
- const { authToken, devUrl, ...safeConfig } = config;
- yield* Effect.logInfo("T3 Code running", {
- ...safeConfig,
- devUrl: devUrl?.toString(),
- authEnabled: Boolean(authToken),
- });
-
- if (!config.noBrowser) {
- const target = config.devUrl?.toString() ?? bindUrl;
- yield* openDeps.openBrowser(target).pipe(
- Effect.catch(() =>
- Effect.logInfo("browser auto-open unavailable", {
- hint: `Open ${target} in your browser.`,
- }),
- ),
- );
- }
-
- return yield* stopSignal;
- }).pipe(Effect.provide(LayerLive(input)));
-
-/**
- * These flags mirrors the environment variables and the config shape.
- */
-
-const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
- Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
- Flag.optional,
-);
-const portFlag = Flag.integer("port").pipe(
- Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
- Flag.withDescription("Port for the HTTP/WebSocket server."),
- Flag.optional,
-);
-const hostFlag = Flag.string("host").pipe(
- Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
- Flag.optional,
-);
-const t3HomeFlag = Flag.string("home-dir").pipe(
- Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
- Flag.optional,
-);
-const devUrlFlag = Flag.string("dev-url").pipe(
- Flag.withSchema(Schema.URLFromString),
- Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
- Flag.optional,
-);
-const noBrowserFlag = Flag.boolean("no-browser").pipe(
- Flag.withDescription("Disable automatic browser opening."),
- Flag.optional,
-);
-const authTokenFlag = Flag.string("auth-token").pipe(
- Flag.withDescription("Auth token required for WebSocket connections."),
- Flag.withAlias("token"),
- Flag.optional,
-);
-const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
- Flag.withDescription(
- "Create a project for the current working directory on startup when missing.",
- ),
- Flag.optional,
-);
-const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
- Flag.withDescription(
- "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
- ),
- Flag.withAlias("log-ws-events"),
- Flag.optional,
-);
-
-export const t3Cli = Command.make("t3", {
- mode: modeFlag,
- port: portFlag,
- host: hostFlag,
- t3Home: t3HomeFlag,
- devUrl: devUrlFlag,
- noBrowser: noBrowserFlag,
- authToken: authTokenFlag,
- autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
- logWebSocketEvents: logWebSocketEventsFlag,
-}).pipe(
- Command.withDescription("Run the T3 Code server."),
- Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
-);
diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts
index e7238c04b2..3bd1dbcd41 100644
--- a/apps/server/src/open.ts
+++ b/apps/server/src/open.ts
@@ -10,17 +10,14 @@ import { spawn } from "node:child_process";
import { accessSync, constants, statSync } from "node:fs";
import { extname, join } from "node:path";
-import { EDITORS, type EditorId } from "@t3tools/contracts";
-import { ServiceMap, Schema, Effect, Layer } from "effect";
+import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
+import { ServiceMap, Effect, Layer } from "effect";
// ==============================
// Definitions
// ==============================
-export class OpenError extends Schema.TaggedErrorClass()("OpenError", {
- message: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {}
+export { OpenError };
export interface OpenInEditorInput {
readonly cwd: string;
diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts
index 69b28b9d3c..5c52379f47 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts
@@ -205,7 +205,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
yield* Effect.forkScoped(worker);
- yield* Effect.log("orchestration engine started").pipe(
+ yield* Effect.logDebug("orchestration engine started").pipe(
Effect.annotateLogs({ sequence: readModel.snapshotSequence }),
);
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index 0651dab646..7cfcc16090 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -3,7 +3,6 @@ import {
type ChatAttachment,
type OrchestrationEvent,
} from "@t3tools/contracts";
-import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";
@@ -1226,7 +1225,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
Effect.provideService(ServerConfig, serverConfig),
Effect.asVoid,
Effect.tap(() =>
- Effect.log("orchestration projection pipeline bootstrapped").pipe(
+ Effect.logDebug("orchestration projection pipeline bootstrapped").pipe(
Effect.annotateLogs({ projectors: projectors.length }),
),
),
@@ -1245,7 +1244,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(
OrchestrationProjectionPipeline,
makeOrchestrationProjectionPipeline,
).pipe(
- Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(ProjectionProjectRepositoryLive),
Layer.provideMerge(ProjectionThreadRepositoryLive),
Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts
new file mode 100644
index 0000000000..7c9d68dc41
--- /dev/null
+++ b/apps/server/src/orchestration/Normalizer.ts
@@ -0,0 +1,129 @@
+import { Effect, FileSystem, Path } from "effect";
+import {
+ type ClientOrchestrationCommand,
+ type OrchestrationCommand,
+ OrchestrationDispatchCommandError,
+ PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
+} from "@t3tools/contracts";
+
+import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore";
+import { ServerConfig } from "../config";
+import { parseBase64DataUrl } from "../imageMime";
+import { expandHomePath } from "../os-jank";
+
+export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const serverConfig = yield* ServerConfig;
+
+ const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
+ Effect.gen(function* () {
+ const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim()));
+ const workspaceStat = yield* fileSystem
+ .stat(normalizedWorkspaceRoot)
+ .pipe(Effect.catch(() => Effect.succeed(null)));
+ if (!workspaceStat) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Project directory does not exist: ${normalizedWorkspaceRoot}`,
+ });
+ }
+ if (workspaceStat.type !== "Directory") {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Project path is not a directory: ${normalizedWorkspaceRoot}`,
+ });
+ }
+ return normalizedWorkspaceRoot;
+ });
+
+ if (command.type === "project.create") {
+ return {
+ ...command,
+ workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
+ } satisfies OrchestrationCommand;
+ }
+
+ if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) {
+ return {
+ ...command,
+ workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
+ } satisfies OrchestrationCommand;
+ }
+
+ if (command.type !== "thread.turn.start") {
+ return command as OrchestrationCommand;
+ }
+
+ const normalizedAttachments = yield* Effect.forEach(
+ command.message.attachments,
+ (attachment) =>
+ Effect.gen(function* () {
+ const parsed = parseBase64DataUrl(attachment.dataUrl);
+ if (!parsed || !parsed.mimeType.startsWith("image/")) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Invalid image attachment payload for '${attachment.name}'.`,
+ });
+ }
+
+ const bytes = Buffer.from(parsed.base64, "base64");
+ if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Image attachment '${attachment.name}' is empty or too large.`,
+ });
+ }
+
+ const attachmentId = createAttachmentId(command.threadId);
+ if (!attachmentId) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: "Failed to create a safe attachment id.",
+ });
+ }
+
+ const persistedAttachment = {
+ type: "image" as const,
+ id: attachmentId,
+ name: attachment.name,
+ mimeType: parsed.mimeType.toLowerCase(),
+ sizeBytes: bytes.byteLength,
+ };
+
+ const attachmentPath = resolveAttachmentPath({
+ attachmentsDir: serverConfig.attachmentsDir,
+ attachment: persistedAttachment,
+ });
+ if (!attachmentPath) {
+ return yield* new OrchestrationDispatchCommandError({
+ message: `Failed to resolve persisted path for '${attachment.name}'.`,
+ });
+ }
+
+ yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestrationDispatchCommandError({
+ message: `Failed to create attachment directory for '${attachment.name}'.`,
+ }),
+ ),
+ );
+ yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestrationDispatchCommandError({
+ message: `Failed to persist attachment '${attachment.name}'.`,
+ }),
+ ),
+ );
+
+ return persistedAttachment;
+ }),
+ { concurrency: 1 },
+ );
+
+ return {
+ ...command,
+ message: {
+ ...command.message,
+ attachments: normalizedAttachments,
+ },
+ } satisfies OrchestrationCommand;
+ });
diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts
index 1d6e22d9b0..d070121ec4 100644
--- a/apps/server/src/persistence/NodeSqliteClient.ts
+++ b/apps/server/src/persistence/NodeSqliteClient.ts
@@ -20,7 +20,7 @@ import * as Stream from "effect/Stream";
import * as Reactivity from "effect/unstable/reactivity/Reactivity";
import * as Client from "effect/unstable/sql/SqlClient";
import type { Connection } from "effect/unstable/sql/SqlConnection";
-import { SqlError } from "effect/unstable/sql/SqlError";
+import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError";
import * as Statement from "effect/unstable/sql/Statement";
const ATTR_DB_SYSTEM_NAME = "db.system.name";
@@ -109,7 +109,10 @@ const makeWithDatabase = (
lookup: (sql: string) =>
Effect.try({
try: () => db.prepare(sql),
- catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }),
+ catch: (cause) =>
+ new SqlError({
+ reason: classifySqliteError(cause, { message: "Failed to prepare statement" }),
+ }),
}),
});
@@ -127,7 +130,11 @@ const makeWithDatabase = (
const result = statement.run(...(params as any));
return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []);
} catch (cause) {
- return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" }));
+ return Effect.fail(
+ new SqlError({
+ reason: classifySqliteError(cause, { message: "Failed to execute statement" }),
+ }),
+ );
}
});
@@ -150,7 +157,10 @@ const makeWithDatabase = (
statement.run(...(params as any));
return [];
},
- catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }),
+ catch: (cause) =>
+ new SqlError({
+ reason: classifySqliteError(cause, { message: "Failed to execute statement" }),
+ }),
}),
(statement) =>
Effect.sync(() => {
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
new file mode 100644
index 0000000000..c284099ad1
--- /dev/null
+++ b/apps/server/src/server.test.ts
@@ -0,0 +1,1265 @@
+import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
+import * as NodeSocket from "@effect/platform-node/NodeSocket";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import {
+ CommandId,
+ GitCommandError,
+ KeybindingRule,
+ OpenError,
+ type OrchestrationEvent,
+ ORCHESTRATION_WS_METHODS,
+ ProjectId,
+ ResolvedKeybindingRule,
+ TerminalError,
+ ThreadId,
+ WS_METHODS,
+ WsRpcGroup,
+ EditorId,
+} from "@t3tools/contracts";
+import { assert, it } from "@effect/vitest";
+import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
+import { Deferred, Effect, Fiber, FileSystem, Layer, Path, Stream } from "effect";
+import { TestClock } from "effect/testing";
+import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
+import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
+
+import type { ServerConfigShape } from "./config.ts";
+import { deriveServerPaths, ServerConfig } from "./config.ts";
+import { makeRoutesLayer } from "./server.ts";
+import { resolveAttachmentRelativePath } from "./attachmentPaths.ts";
+import {
+ CheckpointDiffQuery,
+ type CheckpointDiffQueryShape,
+} from "./checkpointing/Services/CheckpointDiffQuery.ts";
+import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";
+import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
+import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
+import { Open, type OpenShape } from "./open.ts";
+import {
+ OrchestrationEngineService,
+ type OrchestrationEngineShape,
+} from "./orchestration/Services/OrchestrationEngine.ts";
+import {
+ ProjectionSnapshotQuery,
+ type ProjectionSnapshotQueryShape,
+} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
+import { PersistenceSqlError } from "./persistence/Errors.ts";
+import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth.ts";
+import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";
+import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
+import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
+
+const defaultProjectId = ProjectId.makeUnsafe("project-default");
+const defaultThreadId = ThreadId.makeUnsafe("thread-default");
+
+const makeDefaultOrchestrationReadModel = () => {
+ const now = new Date().toISOString();
+ return {
+ snapshotSequence: 0,
+ updatedAt: now,
+ projects: [
+ {
+ id: defaultProjectId,
+ title: "Default Project",
+ workspaceRoot: "/tmp/default-project",
+ defaultModel: "gpt-5-codex",
+ scripts: [],
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ },
+ ],
+ threads: [
+ {
+ id: defaultThreadId,
+ projectId: defaultProjectId,
+ title: "Default Thread",
+ model: "gpt-5-codex",
+ interactionMode: "default" as const,
+ runtimeMode: "full-access" as const,
+ branch: null,
+ worktreePath: null,
+ createdAt: now,
+ updatedAt: now,
+ latestTurn: null,
+ messages: [],
+ session: null,
+ activities: [],
+ proposedPlans: [],
+ checkpoints: [],
+ deletedAt: null,
+ },
+ ],
+ };
+};
+
+const buildAppUnderTest = (options?: {
+ config?: Partial;
+ layers?: {
+ keybindings?: Partial;
+ providerHealth?: Partial;
+ open?: Partial;
+ gitCore?: Partial;
+ gitManager?: Partial;
+ terminalManager?: Partial;
+ orchestrationEngine?: Partial;
+ projectionSnapshotQuery?: Partial;
+ checkpointDiffQuery?: Partial;
+ serverLifecycleEvents?: Partial;
+ serverRuntimeStartup?: Partial;
+ };
+}) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" });
+ const baseDir = options?.config?.baseDir ?? tempBaseDir;
+ const devUrl = options?.config?.devUrl;
+ const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
+ const config = {
+ logLevel: "Info",
+ mode: "web",
+ port: 0,
+ host: "127.0.0.1",
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ staticDir: undefined,
+ devUrl,
+ noBrowser: true,
+ authToken: undefined,
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: false,
+ ...options?.config,
+ } satisfies ServerConfigShape;
+ const layerConfig = Layer.succeed(ServerConfig, config);
+
+ const appLayer = HttpRouter.serve(makeRoutesLayer, {
+ disableListenLog: true,
+ disableLogger: true,
+ }).pipe(
+ Layer.provide(
+ Layer.mock(Keybindings)({
+ streamChanges: Stream.empty,
+ ...options?.layers?.keybindings,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ProviderHealth)({
+ getStatuses: Effect.succeed([]),
+ ...options?.layers?.providerHealth,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(Open)({
+ ...options?.layers?.open,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(GitCore)({
+ ...options?.layers?.gitCore,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(GitManager)({
+ ...options?.layers?.gitManager,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(TerminalManager)({
+ ...options?.layers?.terminalManager,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(OrchestrationEngineService)({
+ getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
+ readEvents: () => Stream.empty,
+ dispatch: () => Effect.succeed({ sequence: 0 }),
+ streamDomainEvents: Stream.empty,
+ ...options?.layers?.orchestrationEngine,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ProjectionSnapshotQuery)({
+ getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
+ ...options?.layers?.projectionSnapshotQuery,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(CheckpointDiffQuery)({
+ getTurnDiff: () =>
+ Effect.succeed({
+ threadId: defaultThreadId,
+ fromTurnCount: 0,
+ toTurnCount: 0,
+ diff: "",
+ }),
+ getFullThreadDiff: () =>
+ Effect.succeed({
+ threadId: defaultThreadId,
+ fromTurnCount: 0,
+ toTurnCount: 0,
+ diff: "",
+ }),
+ ...options?.layers?.checkpointDiffQuery,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ServerLifecycleEvents)({
+ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }),
+ snapshot: Effect.succeed({ sequence: 0, events: [] }),
+ stream: Stream.empty,
+ ...options?.layers?.serverLifecycleEvents,
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ServerRuntimeStartup)({
+ awaitCommandReady: Effect.void,
+ markHttpListening: Effect.void,
+ enqueueCommand: (effect) => effect,
+ ...options?.layers?.serverRuntimeStartup,
+ }),
+ ),
+ Layer.provide(layerConfig),
+ );
+
+ yield* Layer.build(appLayer);
+ return config;
+ });
+
+const wsRpcProtocolLayer = (wsUrl: string) =>
+ RpcClient.layerProtocolSocket().pipe(
+ Layer.provide(NodeSocket.layerWebSocket(wsUrl)),
+ Layer.provide(RpcSerialization.layerJson),
+ );
+
+const makeWsRpcClient = RpcClient.make(WsRpcGroup);
+type WsRpcClient =
+ typeof makeWsRpcClient extends Effect.Effect ? Client : never;
+
+const withWsRpcClient = (
+ wsUrl: string,
+ f: (client: WsRpcClient) => Effect.Effect,
+) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl)));
+
+const getHttpServerUrl = (pathname = "") =>
+ Effect.gen(function* () {
+ const server = yield* HttpServer.HttpServer;
+ const address = server.address as HttpServer.TcpAddress;
+ return `http://127.0.0.1:${address.port}${pathname}`;
+ });
+
+const getWsServerUrl = (pathname = "") =>
+ Effect.gen(function* () {
+ const server = yield* HttpServer.HttpServer;
+ const address = server.address as HttpServer.TcpAddress;
+ return `ws://127.0.0.1:${address.port}${pathname}`;
+ });
+
+it.layer(NodeServices.layer)("server router seam", (it) => {
+ it.effect("routes GET /health through HttpRouter", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const response = yield* HttpClient.get("/health");
+ assert.equal(response.status, 200);
+ assert.deepEqual(yield* response.json, { ok: true });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("serves static index content for GET / when staticDir is configured", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" });
+ const indexPath = path.join(staticDir, "index.html");
+ yield* fileSystem.writeFileString(indexPath, "router-static-ok");
+
+ yield* buildAppUnderTest({ config: { staticDir } });
+
+ const response = yield* HttpClient.get("/");
+ assert.equal(response.status, 200);
+ assert.include(yield* response.text, "router-static-ok");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("redirects to dev URL when configured", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest({
+ config: { devUrl: new URL("http://127.0.0.1:5173") },
+ });
+
+ const url = yield* getHttpServerUrl("/foo/bar");
+ const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" }));
+
+ assert.equal(response.status, 302);
+ assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("serves attachment files from state dir", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const attachmentId = "thread-11111111-1111-4111-8111-111111111111";
+
+ const config = yield* buildAppUnderTest();
+ const attachmentPath = resolveAttachmentRelativePath({
+ attachmentsDir: config.attachmentsDir,
+ relativePath: `${attachmentId}.bin`,
+ });
+ assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
+
+ yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
+ yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
+
+ const response = yield* HttpClient.get(`/attachments/${attachmentId}`);
+ assert.equal(response.status, 200);
+ assert.equal(yield* response.text, "attachment-ok");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("returns 404 for missing attachment id lookups", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const response = yield* HttpClient.get(
+ "/attachments/missing-11111111-1111-4111-8111-111111111111",
+ );
+ assert.equal(response.status, 404);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc server.upsertKeybinding", () =>
+ Effect.gen(function* () {
+ const rule: KeybindingRule = {
+ command: "terminal.toggle",
+ key: "ctrl+k",
+ };
+ const resolved: ResolvedKeybindingRule = {
+ command: "terminal.toggle",
+ shortcut: {
+ key: "k",
+ metaKey: false,
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ modKey: true,
+ },
+ };
+
+ yield* buildAppUnderTest({
+ layers: {
+ keybindings: {
+ upsertKeybindingRule: () => Effect.succeed([resolved]),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverUpsertKeybinding](rule)),
+ );
+
+ assert.deepEqual(response.issues, []);
+ assert.deepEqual(response.keybindings, [resolved]);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("rejects websocket rpc handshake when auth token is missing", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-required-" });
+ yield* fs.writeFileString(
+ path.join(workspaceDir, "needle-file.ts"),
+ "export const needle = 1;",
+ );
+
+ yield* buildAppUnderTest({
+ config: {
+ authToken: "secret-token",
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: workspaceDir,
+ query: "needle",
+ limit: 10,
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertInclude(String(result.failure), "SocketOpenError");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("accepts websocket rpc handshake when auth token is provided", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" });
+ yield* fs.writeFileString(
+ path.join(workspaceDir, "needle-file.ts"),
+ "export const needle = 1;",
+ );
+
+ yield* buildAppUnderTest({
+ config: {
+ authToken: "secret-token",
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws?token=secret-token");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: workspaceDir,
+ query: "needle",
+ limit: 10,
+ }),
+ ),
+ );
+
+ assert.isAtLeast(response.entries.length, 1);
+ assert.equal(response.truncated, false);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
+ Effect.gen(function* () {
+ const providers = [] as const;
+ const changeEvent = {
+ keybindings: [],
+ issues: [],
+ } as const;
+
+ yield* buildAppUnderTest({
+ layers: {
+ keybindings: {
+ loadConfigState: Effect.succeed({
+ keybindings: [],
+ issues: [],
+ }),
+ streamChanges: Stream.succeed(changeEvent),
+ },
+ providerHealth: {
+ getStatuses: Effect.succeed(providers),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
+ ),
+ );
+
+ const [first, second] = Array.from(events);
+ assert.equal(first?.type, "snapshot");
+ if (first?.type === "snapshot") {
+ assert.equal(first.version, 1);
+ assert.deepEqual(first.config.keybindings, []);
+ assert.deepEqual(first.config.issues, []);
+ assert.deepEqual(first.config.providers, providers);
+ }
+ assert.deepEqual(second, {
+ version: 1,
+ type: "keybindingsUpdated",
+ payload: { issues: [] },
+ });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc subscribeServerConfig emits providerStatuses heartbeat", () =>
+ Effect.gen(function* () {
+ const providers = [] as const;
+
+ yield* buildAppUnderTest({
+ layers: {
+ keybindings: {
+ loadConfigState: Effect.succeed({
+ keybindings: [],
+ issues: [],
+ }),
+ streamChanges: Stream.empty,
+ },
+ providerHealth: {
+ getStatuses: Effect.succeed(providers),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const snapshotReceived = yield* Deferred.make();
+ const eventsFiber = yield* withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeServerConfig]({}).pipe(
+ Stream.tap((event) =>
+ event.type === "snapshot"
+ ? Deferred.succeed(snapshotReceived, undefined).pipe(Effect.ignore)
+ : Effect.void,
+ ),
+ Stream.take(2),
+ Stream.runCollect,
+ ),
+ ).pipe(Effect.forkScoped);
+
+ yield* Deferred.await(snapshotReceived);
+ yield* TestClock.adjust("10 seconds");
+ return yield* Fiber.join(eventsFiber);
+ }),
+ );
+
+ const [first, second] = Array.from(events);
+ assert.equal(first?.type, "snapshot");
+ assert.deepEqual(second, {
+ version: 1,
+ type: "providerStatuses",
+ payload: { providers },
+ });
+ }).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, TestClock.layer()))),
+ );
+
+ it.effect(
+ "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates",
+ () =>
+ Effect.gen(function* () {
+ const lifecycleEvents = [
+ {
+ version: 1 as const,
+ sequence: 1,
+ type: "welcome" as const,
+ payload: {
+ cwd: "/tmp/project",
+ projectName: "project",
+ },
+ },
+ ] as const;
+ const liveEvents = Stream.make({
+ version: 1 as const,
+ sequence: 2,
+ type: "ready" as const,
+ payload: { at: new Date().toISOString() },
+ });
+
+ yield* buildAppUnderTest({
+ layers: {
+ serverLifecycleEvents: {
+ snapshot: Effect.succeed({
+ sequence: 1,
+ events: lifecycleEvents,
+ }),
+ stream: liveEvents,
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect),
+ ),
+ );
+
+ const [first, second] = Array.from(events);
+ assert.equal(first?.type, "welcome");
+ assert.equal(first?.sequence, 1);
+ assert.equal(second?.type, "ready");
+ assert.equal(second?.sequence, 2);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.searchEntries", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-search-" });
+ yield* fs.writeFileString(
+ path.join(workspaceDir, "needle-file.ts"),
+ "export const needle = 1;",
+ );
+
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: workspaceDir,
+ query: "needle",
+ limit: 10,
+ }),
+ ),
+ );
+
+ assert.isAtLeast(response.entries.length, 1);
+ assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts"));
+ assert.equal(response.truncated, false);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.searchEntries errors", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsSearchEntries]({
+ cwd: "/definitely/not/a/real/workspace/path",
+ query: "needle",
+ limit: 10,
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertTrue(result.failure._tag === "ProjectSearchEntriesError");
+ assertInclude(
+ String(result.failure.cause),
+ "ENOENT: no such file or directory, scandir '/definitely/not/a/real/workspace/path'",
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.writeFile", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
+
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const response = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsWriteFile]({
+ cwd: workspaceDir,
+ relativePath: "nested/created.txt",
+ contents: "written-by-rpc",
+ }),
+ ),
+ );
+
+ assert.equal(response.relativePath, "nested/created.txt");
+ const persisted = yield* fs.readFileString(path.join(workspaceDir, "nested", "created.txt"));
+ assert.equal(persisted, "written-by-rpc");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc projects.writeFile errors", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
+
+ yield* buildAppUnderTest();
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.projectsWriteFile]({
+ cwd: workspaceDir,
+ relativePath: "../escape.txt",
+ contents: "nope",
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertTrue(result.failure._tag === "ProjectWriteFileError");
+ assert.equal(
+ result.failure.message,
+ "Workspace file path must stay within the project root.",
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc shell.openInEditor", () =>
+ Effect.gen(function* () {
+ let openedInput: { cwd: string; editor: EditorId } | null = null;
+ yield* buildAppUnderTest({
+ layers: {
+ open: {
+ openInEditor: (input) =>
+ Effect.sync(() => {
+ openedInput = input;
+ }),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.shellOpenInEditor]({
+ cwd: "/tmp/project",
+ editor: "cursor",
+ }),
+ ),
+ );
+
+ assert.deepEqual(openedInput, { cwd: "/tmp/project", editor: "cursor" });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc shell.openInEditor errors", () =>
+ Effect.gen(function* () {
+ const openError = new OpenError({ message: "Editor command not found: cursor" });
+ yield* buildAppUnderTest({
+ layers: {
+ open: {
+ openInEditor: () => Effect.fail(openError),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.shellOpenInEditor]({
+ cwd: "/tmp/project",
+ editor: "cursor",
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertFailure(result, openError);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc git methods", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest({
+ layers: {
+ gitManager: {
+ status: () =>
+ Effect.succeed({
+ branch: "main",
+ hasWorkingTreeChanges: false,
+ workingTree: { files: [], insertions: 0, deletions: 0 },
+ hasUpstream: true,
+ aheadCount: 0,
+ behindCount: 0,
+ pr: null,
+ }),
+ runStackedAction: () =>
+ Effect.succeed({
+ action: "commit",
+ branch: { status: "skipped_not_requested" },
+ commit: { status: "created", commitSha: "abc123", subject: "feat: demo" },
+ push: { status: "skipped_not_requested" },
+ pr: { status: "skipped_not_requested" },
+ }),
+ resolvePullRequest: () =>
+ Effect.succeed({
+ pullRequest: {
+ number: 1,
+ title: "Demo PR",
+ url: "https://example.com/pr/1",
+ baseBranch: "main",
+ headBranch: "feature/demo",
+ state: "open",
+ },
+ }),
+ preparePullRequestThread: () =>
+ Effect.succeed({
+ pullRequest: {
+ number: 1,
+ title: "Demo PR",
+ url: "https://example.com/pr/1",
+ baseBranch: "main",
+ headBranch: "feature/demo",
+ state: "open",
+ },
+ branch: "feature/demo",
+ worktreePath: null,
+ }),
+ },
+ gitCore: {
+ pullCurrentBranch: () =>
+ Effect.succeed({
+ status: "pulled",
+ branch: "main",
+ upstreamBranch: "origin/main",
+ }),
+ listBranches: () =>
+ Effect.succeed({
+ branches: [
+ {
+ name: "main",
+ current: true,
+ isDefault: true,
+ worktreePath: null,
+ },
+ ],
+ isRepo: true,
+ hasOriginRemote: true,
+ }),
+ createWorktree: () =>
+ Effect.succeed({
+ worktree: { path: "/tmp/wt", branch: "feature/demo" },
+ }),
+ removeWorktree: () => Effect.void,
+ createBranch: () => Effect.void,
+ checkoutBranch: () => Effect.void,
+ initRepo: () => Effect.void,
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+
+ const status = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })),
+ );
+ assert.equal(status.branch, "main");
+
+ const pull = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
+ );
+ assert.equal(pull.status, "pulled");
+
+ const stacked = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitRunStackedAction]({ cwd: "/tmp/repo", action: "commit" }),
+ ),
+ );
+ assert.equal(stacked.action, "commit");
+
+ const resolvedPr = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitResolvePullRequest]({
+ cwd: "/tmp/repo",
+ reference: "1",
+ }),
+ ),
+ );
+ assert.equal(resolvedPr.pullRequest.number, 1);
+
+ const prepared = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitPreparePullRequestThread]({
+ cwd: "/tmp/repo",
+ reference: "1",
+ mode: "local",
+ }),
+ ),
+ );
+ assert.equal(prepared.branch, "feature/demo");
+
+ const branches = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }),
+ ),
+ );
+ assert.equal(branches.branches[0]?.name, "main");
+
+ const worktree = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitCreateWorktree]({
+ cwd: "/tmp/repo",
+ branch: "main",
+ path: null,
+ }),
+ ),
+ );
+ assert.equal(worktree.worktree.branch, "feature/demo");
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitRemoveWorktree]({
+ cwd: "/tmp/repo",
+ path: "/tmp/wt",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitCreateBranch]({
+ cwd: "/tmp/repo",
+ branch: "feature/new",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitCheckout]({
+ cwd: "/tmp/repo",
+ branch: "main",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.gitInit]({
+ cwd: "/tmp/repo",
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc git.pull errors", () =>
+ Effect.gen(function* () {
+ const gitError = new GitCommandError({
+ operation: "pull",
+ command: "git pull --ff-only",
+ cwd: "/tmp/repo",
+ detail: "upstream missing",
+ });
+ yield* buildAppUnderTest({
+ layers: {
+ gitCore: {
+ pullCurrentBranch: () => Effect.fail(gitError),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe(
+ Effect.result,
+ ),
+ );
+
+ assertFailure(result, gitError);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc orchestration methods", () =>
+ Effect.gen(function* () {
+ const now = new Date().toISOString();
+ const snapshot = {
+ snapshotSequence: 1,
+ updatedAt: now,
+ projects: [
+ {
+ id: ProjectId.makeUnsafe("project-a"),
+ title: "Project A",
+ workspaceRoot: "/tmp/project-a",
+ defaultModel: "gpt-5-codex",
+ scripts: [],
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ },
+ ],
+ threads: [
+ {
+ id: ThreadId.makeUnsafe("thread-1"),
+ projectId: ProjectId.makeUnsafe("project-a"),
+ title: "Thread A",
+ model: "gpt-5-codex",
+ interactionMode: "default" as const,
+ runtimeMode: "full-access" as const,
+ branch: null,
+ worktreePath: null,
+ createdAt: now,
+ updatedAt: now,
+ latestTurn: null,
+ messages: [],
+ session: null,
+ activities: [],
+ proposedPlans: [],
+ checkpoints: [],
+ deletedAt: null,
+ },
+ ],
+ };
+
+ yield* buildAppUnderTest({
+ layers: {
+ projectionSnapshotQuery: {
+ getSnapshot: () => Effect.succeed(snapshot),
+ },
+ orchestrationEngine: {
+ dispatch: () => Effect.succeed({ sequence: 7 }),
+ readEvents: () => Stream.empty,
+ },
+ checkpointDiffQuery: {
+ getTurnDiff: () =>
+ Effect.succeed({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ fromTurnCount: 0,
+ toTurnCount: 1,
+ diff: "turn-diff",
+ }),
+ getFullThreadDiff: () =>
+ Effect.succeed({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ fromTurnCount: 0,
+ toTurnCount: 1,
+ diff: "full-diff",
+ }),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const snapshotResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
+ );
+ assert.equal(snapshotResult.snapshotSequence, 1);
+
+ const dispatchResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
+ type: "thread.session.stop",
+ commandId: CommandId.makeUnsafe("cmd-1"),
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ createdAt: now,
+ }),
+ ),
+ );
+ assert.equal(dispatchResult.sequence, 7);
+
+ const turnDiffResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.getTurnDiff]({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ fromTurnCount: 0,
+ toTurnCount: 1,
+ }),
+ ),
+ );
+ assert.equal(turnDiffResult.diff, "turn-diff");
+
+ const fullDiffResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ toTurnCount: 1,
+ }),
+ ),
+ );
+ assert.equal(fullDiffResult.diff, "full-diff");
+
+ const replayResult = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.replayEvents]({
+ fromSequenceExclusive: 0,
+ }),
+ ),
+ );
+ assert.deepEqual(replayResult, []);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect(
+ "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience",
+ () =>
+ Effect.gen(function* () {
+ const now = new Date().toISOString();
+ const threadId = ThreadId.makeUnsafe("thread-1");
+ let replayCursor: number | null = null;
+ const makeEvent = (sequence: number): OrchestrationEvent =>
+ ({
+ sequence,
+ eventId: `event-${sequence}`,
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: now,
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ type: "thread.reverted",
+ payload: {
+ threadId,
+ turnCount: sequence,
+ },
+ }) as OrchestrationEvent;
+
+ yield* buildAppUnderTest({
+ layers: {
+ orchestrationEngine: {
+ getReadModel: () =>
+ Effect.succeed({
+ ...makeDefaultOrchestrationReadModel(),
+ snapshotSequence: 1,
+ }),
+ readEvents: (fromSequenceExclusive) => {
+ replayCursor = fromSequenceExclusive;
+ return Stream.make(makeEvent(2), makeEvent(3));
+ },
+ streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
+ Stream.take(3),
+ Stream.runCollect,
+ ),
+ ),
+ );
+
+ assert.equal(replayCursor, 1);
+ assert.deepEqual(
+ Array.from(events).map((event) => event.sequence),
+ [2, 3, 4],
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest({
+ layers: {
+ projectionSnapshotQuery: {
+ getSnapshot: () =>
+ Effect.fail(
+ new PersistenceSqlError({
+ operation: "ProjectionSnapshotQuery.getSnapshot",
+ detail: "projection unavailable",
+ }),
+ ),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe(
+ Effect.result,
+ ),
+ );
+
+ assertTrue(result._tag === "Failure");
+ assertTrue(result.failure._tag === "OrchestrationGetSnapshotError");
+ assertInclude(result.failure.message, "Failed to load orchestration snapshot");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc terminal methods", () =>
+ Effect.gen(function* () {
+ const snapshot = {
+ threadId: "thread-1",
+ terminalId: "default",
+ cwd: "/tmp/project",
+ status: "running" as const,
+ pid: 1234,
+ history: "",
+ exitCode: null,
+ exitSignal: null,
+ updatedAt: new Date().toISOString(),
+ };
+
+ yield* buildAppUnderTest({
+ layers: {
+ terminalManager: {
+ open: () => Effect.succeed(snapshot),
+ write: () => Effect.void,
+ resize: () => Effect.void,
+ clear: () => Effect.void,
+ restart: () => Effect.succeed(snapshot),
+ close: () => Effect.void,
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+
+ const opened = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalOpen]({
+ threadId: "thread-1",
+ terminalId: "default",
+ cwd: "/tmp/project",
+ }),
+ ),
+ );
+ assert.equal(opened.terminalId, "default");
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalWrite]({
+ threadId: "thread-1",
+ terminalId: "default",
+ data: "echo hi\n",
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalResize]({
+ threadId: "thread-1",
+ terminalId: "default",
+ cols: 120,
+ rows: 40,
+ }),
+ ),
+ );
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalClear]({
+ threadId: "thread-1",
+ terminalId: "default",
+ }),
+ ),
+ );
+
+ const restarted = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalRestart]({
+ threadId: "thread-1",
+ terminalId: "default",
+ cwd: "/tmp/project",
+ cols: 120,
+ rows: 40,
+ }),
+ ),
+ );
+ assert.equal(restarted.terminalId, "default");
+
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalClose]({
+ threadId: "thread-1",
+ terminalId: "default",
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("routes websocket rpc terminal.write errors", () =>
+ Effect.gen(function* () {
+ const terminalError = new TerminalError({ message: "Terminal is not running" });
+ yield* buildAppUnderTest({
+ layers: {
+ terminalManager: {
+ write: () => Effect.fail(terminalError),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.terminalWrite]({
+ threadId: "thread-1",
+ terminalId: "default",
+ data: "echo fail\n",
+ }),
+ ).pipe(Effect.result),
+ );
+
+ assertFailure(result, terminalError);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+});
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
new file mode 100644
index 0000000000..16d67b9d15
--- /dev/null
+++ b/apps/server/src/server.ts
@@ -0,0 +1,217 @@
+import { Effect, Layer } from "effect";
+import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
+
+import { ServerConfig } from "./config";
+import { attachmentsRouteLayer, healthRouteLayer, staticAndDevRouteLayer } from "./http";
+import { fixPath } from "./os-jank";
+import { websocketRpcRouteLayer } from "./ws";
+import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
+import { OpenLive } from "./open";
+import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite";
+import { ServerLifecycleEventsLive } from "./serverLifecycleEvents";
+import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
+import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
+import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
+import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
+import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
+import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
+import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
+import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
+import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
+import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
+import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
+import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
+import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
+import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
+import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
+import { GitCoreLive } from "./git/Layers/GitCore";
+import { GitHubCliLive } from "./git/Layers/GitHubCli";
+import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
+import { TerminalManagerLive } from "./terminal/Layers/Manager";
+import { GitManagerLive } from "./git/Layers/GitManager";
+import { KeybindingsLive } from "./keybindings";
+import { ServerLoggerLive } from "./serverLogger";
+import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup";
+import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
+import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
+import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
+import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
+import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
+
+const PtyAdapterLive = Layer.unwrap(
+ Effect.gen(function* () {
+ if (typeof Bun !== "undefined") {
+ const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY"));
+ return BunPTY.layer;
+ } else {
+ const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY"));
+ return NodePTY.layer;
+ }
+ }),
+);
+
+const HttpServerLive = Layer.unwrap(
+ Effect.gen(function* () {
+ const config = yield* ServerConfig;
+ if (typeof Bun !== "undefined") {
+ const BunHttpServer = yield* Effect.promise(
+ () => import("@effect/platform-bun/BunHttpServer"),
+ );
+ return BunHttpServer.layer({
+ port: config.port,
+ ...(config.host ? { hostname: config.host } : {}),
+ });
+ } else {
+ const [NodeHttpServer, NodeHttp] = yield* Effect.all([
+ Effect.promise(() => import("@effect/platform-node/NodeHttpServer")),
+ Effect.promise(() => import("node:http")),
+ ]);
+ return NodeHttpServer.layer(NodeHttp.createServer, {
+ host: config.host,
+ port: config.port,
+ });
+ }
+ }),
+);
+
+const PlatformServicesLive = Layer.unwrap(
+ Effect.gen(function* () {
+ if (typeof Bun !== "undefined") {
+ const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices"));
+ return layer;
+ } else {
+ const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices"));
+ return layer;
+ }
+ }),
+);
+
+const ReactorLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(OrchestrationReactorLive),
+ Layer.provideMerge(ProviderRuntimeIngestionLive),
+ Layer.provideMerge(ProviderCommandReactorLive),
+ Layer.provideMerge(CheckpointReactorLive),
+ Layer.provideMerge(RuntimeReceiptBusLive),
+);
+
+const OrchestrationLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
+ Layer.provideMerge(OrchestrationEngineLive),
+ Layer.provideMerge(OrchestrationProjectionPipelineLive),
+ Layer.provideMerge(OrchestrationEventStoreLive),
+ Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
+);
+
+const CheckpointingLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(CheckpointDiffQueryLive),
+ Layer.provideMerge(CheckpointStoreLive),
+);
+
+const ProviderLayerLive = Layer.unwrap(
+ Effect.gen(function* () {
+ const { providerEventLogPath } = yield* ServerConfig;
+ const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
+ stream: "native",
+ });
+ const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
+ stream: "canonical",
+ });
+ const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
+ Layer.provide(ProviderSessionRuntimeRepositoryLive),
+ );
+ const codexAdapterLayer = makeCodexAdapterLive(
+ nativeEventLogger ? { nativeEventLogger } : undefined,
+ );
+ const claudeAdapterLayer = makeClaudeAdapterLive(
+ nativeEventLogger ? { nativeEventLogger } : undefined,
+ );
+ const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
+ Layer.provide(codexAdapterLayer),
+ Layer.provide(claudeAdapterLayer),
+ Layer.provideMerge(providerSessionDirectoryLayer),
+ );
+ return makeProviderServiceLive(
+ canonicalEventLogger ? { canonicalEventLogger } : undefined,
+ ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
+ }),
+);
+
+const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
+
+const GitLayerLive = Layer.empty.pipe(
+ Layer.provideMerge(
+ GitManagerLive.pipe(
+ Layer.provideMerge(GitCoreLive),
+ Layer.provideMerge(GitHubCliLive),
+ Layer.provideMerge(CodexTextGenerationLive),
+ ),
+ ),
+ Layer.provideMerge(GitCoreLive),
+);
+
+const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
+
+const RuntimeServicesLive = Layer.empty.pipe(
+ Layer.provideMerge(ServerRuntimeStartupLive),
+ Layer.provideMerge(ReactorLayerLive),
+
+ // Core Services
+ Layer.provideMerge(CheckpointingLayerLive),
+ Layer.provideMerge(OrchestrationLayerLive),
+ Layer.provideMerge(ProviderLayerLive),
+ Layer.provideMerge(GitLayerLive),
+ Layer.provideMerge(TerminalLayerLive),
+ Layer.provideMerge(PersistenceLayerLive),
+ Layer.provideMerge(KeybindingsLive),
+
+ // Misc.
+ Layer.provideMerge(AnalyticsServiceLayerLive),
+ Layer.provideMerge(OpenLive),
+ Layer.provideMerge(ProviderHealthLive),
+ Layer.provideMerge(ServerLifecycleEventsLive),
+);
+
+export const makeRoutesLayer = Layer.mergeAll(
+ healthRouteLayer,
+ attachmentsRouteLayer,
+ staticAndDevRouteLayer,
+ websocketRpcRouteLayer,
+);
+
+export const makeServerLayer = Layer.unwrap(
+ Effect.gen(function* () {
+ const config = yield* ServerConfig;
+
+ fixPath();
+
+ const httpListeningLayer = Layer.effectDiscard(
+ Effect.gen(function* () {
+ yield* HttpServer.HttpServer;
+ const startup = yield* ServerRuntimeStartup;
+ yield* startup.markHttpListening;
+ }),
+ );
+
+ const serverApplicationLayer = Layer.mergeAll(
+ HttpRouter.serve(makeRoutesLayer, {
+ disableLogger: !config.logWebSocketEvents,
+ }),
+ httpListeningLayer,
+ );
+
+ return serverApplicationLayer.pipe(
+ Layer.provideMerge(RuntimeServicesLive),
+ Layer.provideMerge(HttpServerLive),
+ Layer.provide(ServerLoggerLive),
+ Layer.provideMerge(FetchHttpClient.layer),
+ Layer.provideMerge(PlatformServicesLive),
+ );
+ }),
+);
+
+// Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer.
+export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect<
+ never,
+ any,
+ ServerConfig
+>;
diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts
deleted file mode 100644
index 1cd8edac26..0000000000
--- a/apps/server/src/serverLayers.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import { Effect, FileSystem, Layer, Path } from "effect";
-import * as SqlClient from "effect/unstable/sql/SqlClient";
-
-import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
-import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
-import { ServerConfig } from "./config";
-import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
-import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
-import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
-import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
-import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
-import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
-import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
-import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
-import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
-import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
-import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
-import { ProviderUnsupportedError } from "./provider/Errors";
-import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
-import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
-import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
-import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
-import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
-import { ProviderService } from "./provider/Services/ProviderService";
-import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
-
-import { TerminalManagerLive } from "./terminal/Layers/Manager";
-import { KeybindingsLive } from "./keybindings";
-import { GitManagerLive } from "./git/Layers/GitManager";
-import { GitCoreLive } from "./git/Layers/GitCore";
-import { GitHubCliLive } from "./git/Layers/GitHubCli";
-import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
-import { PtyAdapter } from "./terminal/Services/PTY";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
-
-type RuntimePtyAdapterLoader = {
- layer: Layer.Layer;
-};
-
-const runtimePtyAdapterLoaders = {
- bun: () => import("./terminal/Layers/BunPTY"),
- node: () => import("./terminal/Layers/NodePTY"),
-} satisfies Record Promise>;
-
-const makeRuntimePtyAdapterLayer = () =>
- Effect.gen(function* () {
- const runtime = process.versions.bun !== undefined ? "bun" : "node";
- const loader = runtimePtyAdapterLoaders[runtime];
- const ptyAdapterModule = yield* Effect.promise(loader);
- return ptyAdapterModule.layer;
- }).pipe(Layer.unwrap);
-
-export function makeServerProviderLayer(): Layer.Layer<
- ProviderService,
- ProviderUnsupportedError,
- SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService
-> {
- return Effect.gen(function* () {
- const { providerEventLogPath } = yield* ServerConfig;
- const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
- stream: "native",
- });
- const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
- stream: "canonical",
- });
- const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
- Layer.provide(ProviderSessionRuntimeRepositoryLive),
- );
- const codexAdapterLayer = makeCodexAdapterLive(
- nativeEventLogger ? { nativeEventLogger } : undefined,
- );
- const claudeAdapterLayer = makeClaudeAdapterLive(
- nativeEventLogger ? { nativeEventLogger } : undefined,
- );
- const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
- Layer.provide(codexAdapterLayer),
- Layer.provide(claudeAdapterLayer),
- Layer.provideMerge(providerSessionDirectoryLayer),
- );
- return makeProviderServiceLive(
- canonicalEventLogger ? { canonicalEventLogger } : undefined,
- ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
- }).pipe(Layer.unwrap);
-}
-
-export function makeServerRuntimeServicesLayer() {
- const textGenerationLayer = CodexTextGenerationLive;
- const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
-
- const orchestrationLayer = OrchestrationEngineLive.pipe(
- Layer.provide(OrchestrationProjectionPipelineLive),
- Layer.provide(OrchestrationEventStoreLive),
- Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- );
-
- const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
- Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
- Layer.provideMerge(checkpointStoreLayer),
- );
-
- const runtimeServicesLayer = Layer.mergeAll(
- orchestrationLayer,
- OrchestrationProjectionSnapshotQueryLive,
- checkpointStoreLayer,
- checkpointDiffQueryLayer,
- RuntimeReceiptBusLive,
- );
- const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
- Layer.provideMerge(runtimeServicesLayer),
- );
- const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
- Layer.provideMerge(runtimeServicesLayer),
- Layer.provideMerge(GitCoreLive),
- Layer.provideMerge(textGenerationLayer),
- );
- const checkpointReactorLayer = CheckpointReactorLive.pipe(
- Layer.provideMerge(runtimeServicesLayer),
- );
- const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
- Layer.provideMerge(runtimeIngestionLayer),
- Layer.provideMerge(providerCommandReactorLayer),
- Layer.provideMerge(checkpointReactorLayer),
- );
-
- const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));
-
- const gitManagerLayer = GitManagerLive.pipe(
- Layer.provideMerge(GitCoreLive),
- Layer.provideMerge(GitHubCliLive),
- Layer.provideMerge(textGenerationLayer),
- );
-
- return Layer.mergeAll(
- orchestrationReactorLayer,
- GitCoreLive,
- gitManagerLayer,
- terminalLayer,
- KeybindingsLive,
- ).pipe(Layer.provideMerge(NodeServices.layer));
-}
diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts
new file mode 100644
index 0000000000..1cd8c25c03
--- /dev/null
+++ b/apps/server/src/serverLifecycleEvents.test.ts
@@ -0,0 +1,42 @@
+import { assert, it } from "@effect/vitest";
+import { assertTrue } from "@effect/vitest/utils";
+import { Effect, Option } from "effect";
+
+import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";
+
+it.effect(
+ "publishes lifecycle events without subscribers and snapshots the latest welcome/ready",
+ () =>
+ Effect.gen(function* () {
+ const lifecycleEvents = yield* ServerLifecycleEvents;
+
+ const welcome = yield* lifecycleEvents
+ .publish({
+ version: 1,
+ type: "welcome",
+ payload: {
+ cwd: "/tmp/project",
+ projectName: "project",
+ },
+ })
+ .pipe(Effect.timeoutOption("50 millis"));
+ assertTrue(Option.isSome(welcome));
+ assert.equal(welcome.value.sequence, 1);
+
+ const ready = yield* lifecycleEvents
+ .publish({
+ version: 1,
+ type: "ready",
+ payload: {
+ at: new Date().toISOString(),
+ },
+ })
+ .pipe(Effect.timeoutOption("50 millis"));
+ assertTrue(Option.isSome(ready));
+ assert.equal(ready.value.sequence, 2);
+
+ const snapshot = yield* lifecycleEvents.snapshot;
+ assert.equal(snapshot.sequence, 2);
+ assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]);
+ }).pipe(Effect.provide(ServerLifecycleEventsLive)),
+);
diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts
new file mode 100644
index 0000000000..4808a19d72
--- /dev/null
+++ b/apps/server/src/serverLifecycleEvents.ts
@@ -0,0 +1,53 @@
+import type { ServerLifecycleStreamEvent } from "@t3tools/contracts";
+import { Effect, Layer, PubSub, Ref, ServiceMap, Stream } from "effect";
+
+type LifecycleEventInput =
+ | Omit, "sequence">
+ | Omit, "sequence">;
+
+interface SnapshotState {
+ readonly sequence: number;
+ readonly events: ReadonlyArray;
+}
+
+export interface ServerLifecycleEventsShape {
+ readonly publish: (event: LifecycleEventInput) => Effect.Effect;
+ readonly snapshot: Effect.Effect;
+ readonly stream: Stream.Stream;
+}
+
+export class ServerLifecycleEvents extends ServiceMap.Service<
+ ServerLifecycleEvents,
+ ServerLifecycleEventsShape
+>()("t3/serverLifecycleEvents") {}
+
+export const ServerLifecycleEventsLive = Layer.effect(
+ ServerLifecycleEvents,
+ Effect.gen(function* () {
+ const pubsub = yield* PubSub.unbounded();
+ const state = yield* Ref.make({
+ sequence: 0,
+ events: [],
+ });
+
+ return {
+ publish: (event) =>
+ Ref.modify(state, (current) => {
+ const nextSequence = current.sequence + 1;
+ const nextEvent = {
+ ...event,
+ sequence: nextSequence,
+ } satisfies ServerLifecycleStreamEvent;
+ const nextEvents =
+ nextEvent.type === "welcome"
+ ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")]
+ : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")];
+ return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const;
+ }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))),
+ snapshot: Ref.get(state),
+ get stream() {
+ return Stream.fromPubSub(pubsub);
+ },
+ } satisfies ServerLifecycleEventsShape;
+ }),
+);
diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts
new file mode 100644
index 0000000000..113013909d
--- /dev/null
+++ b/apps/server/src/serverLogger.test.ts
@@ -0,0 +1,48 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import { Effect, FileSystem, Layer, LogLevel, Path, References } from "effect";
+
+import { deriveServerPaths, ServerConfig } from "./config.ts";
+import { ServerLoggerLive } from "./serverLogger.ts";
+
+it.layer(NodeServices.layer)("ServerLoggerLive", (it) => {
+ it.effect("provides the configured minimum log level and initializes log storage", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-server-logger-",
+ });
+ const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
+ const configLayer = Layer.succeed(ServerConfig, {
+ logLevel: "Warn",
+ mode: "web",
+ port: 0,
+ host: undefined,
+ cwd: process.cwd(),
+ baseDir,
+ ...derivedPaths,
+ staticDir: undefined,
+ devUrl: undefined,
+ noBrowser: true,
+ authToken: undefined,
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: false,
+ });
+
+ const result = yield* Effect.gen(function* () {
+ return {
+ minimumLogLevel: yield* References.MinimumLogLevel,
+ debugEnabled: yield* LogLevel.isEnabled("Debug"),
+ warnEnabled: yield* LogLevel.isEnabled("Warn"),
+ logDirExists: yield* fileSystem.exists(path.join(baseDir, "userdata", "logs")),
+ };
+ }).pipe(Effect.provide(ServerLoggerLive.pipe(Layer.provide(configLayer))));
+
+ assert.equal(result.minimumLogLevel, "Warn");
+ assert.isFalse(result.debugEnabled);
+ assert.isTrue(result.warnEnabled);
+ assert.isTrue(result.logDirExists);
+ }),
+ );
+});
diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts
index 1b90babaad..c7392a0592 100644
--- a/apps/server/src/serverLogger.ts
+++ b/apps/server/src/serverLogger.ts
@@ -1,20 +1,23 @@
import fs from "node:fs";
-import { Effect, Logger } from "effect";
+import { Effect, Logger, References } from "effect";
import * as Layer from "effect/Layer";
import { ServerConfig } from "./config";
export const ServerLoggerLive = Effect.gen(function* () {
- const { logsDir, serverLogPath } = yield* ServerConfig;
+ const config = yield* ServerConfig;
+ const { logsDir, serverLogPath } = config;
yield* Effect.sync(() => {
fs.mkdirSync(logsDir, { recursive: true });
});
const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath));
-
- return Logger.layer([Logger.defaultLogger, fileLogger], {
+ const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
+ const loggerLayer = Logger.layer([Logger.consolePretty(), fileLogger], {
mergeWithExisting: false,
});
+
+ return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
}).pipe(Layer.unwrap);
diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts
new file mode 100644
index 0000000000..55700e3482
--- /dev/null
+++ b/apps/server/src/serverRuntimeStartup.test.ts
@@ -0,0 +1,49 @@
+import { assert, it } from "@effect/vitest";
+import { Deferred, Effect, Fiber, Ref } from "effect";
+import { TestClock } from "effect/testing";
+
+import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts";
+
+it.effect("enqueueCommand waits for readiness and then drains queued work", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const executionCount = yield* Ref.make(0);
+ const commandGate = yield* makeCommandGate;
+
+ const queuedCommandFiber = yield* commandGate
+ .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1))
+ .pipe(Effect.forkScoped);
+
+ yield* TestClock.adjust("50 millis");
+ assert.equal(yield* Ref.get(executionCount), 0);
+
+ yield* commandGate.signalCommandReady;
+
+ const result = yield* Fiber.join(queuedCommandFiber);
+ assert.equal(result, 1);
+ assert.equal(yield* Ref.get(executionCount), 1);
+ }),
+ ),
+);
+
+it.effect("enqueueCommand fails queued work when readiness fails", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const commandGate = yield* makeCommandGate;
+ const failure = yield* Deferred.make();
+
+ const queuedCommandFiber = yield* commandGate
+ .enqueueCommand(Deferred.await(failure).pipe(Effect.as("should-not-run")))
+ .pipe(Effect.forkScoped);
+
+ yield* commandGate.failCommandReady(
+ new ServerRuntimeStartupError({
+ message: "startup failed",
+ }),
+ );
+
+ const error = yield* Effect.flip(Fiber.join(queuedCommandFiber));
+ assert.equal(error.message, "startup failed");
+ }),
+ ),
+);
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
new file mode 100644
index 0000000000..90fb9fd95c
--- /dev/null
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -0,0 +1,313 @@
+import {
+ CommandId,
+ DEFAULT_PROVIDER_INTERACTION_MODE,
+ ProjectId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect";
+
+import { ServerConfig } from "./config";
+import { Keybindings } from "./keybindings";
+import { Open } from "./open";
+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
+import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
+import { ServerLifecycleEvents } from "./serverLifecycleEvents";
+import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
+
+const isWildcardHost = (host: string | undefined): boolean =>
+ host === "0.0.0.0" || host === "::" || host === "[::]";
+
+const formatHostForUrl = (host: string): string =>
+ host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+
+export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
+ readonly message: string;
+ readonly cause?: unknown;
+}> {}
+
+export interface ServerRuntimeStartupShape {
+ readonly awaitCommandReady: Effect.Effect;
+ readonly markHttpListening: Effect.Effect;
+ readonly enqueueCommand: (
+ effect: Effect.Effect,
+ ) => Effect.Effect;
+}
+
+export class ServerRuntimeStartup extends ServiceMap.Service<
+ ServerRuntimeStartup,
+ ServerRuntimeStartupShape
+>()("t3/serverRuntimeStartup") {}
+
+interface QueuedCommand {
+ readonly run: Effect.Effect;
+}
+
+type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError;
+
+interface CommandGate {
+ readonly awaitCommandReady: Effect.Effect;
+ readonly signalCommandReady: Effect.Effect;
+ readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect;
+ readonly enqueueCommand: (
+ effect: Effect.Effect,
+ ) => Effect.Effect;
+}
+
+const settleQueuedCommand = (deferred: Deferred.Deferred, exit: Exit.Exit) =>
+ Exit.isSuccess(exit)
+ ? Deferred.succeed(deferred, exit.value)
+ : Deferred.failCause(deferred, exit.cause);
+
+export const makeCommandGate = Effect.gen(function* () {
+ const commandReady = yield* Deferred.make();
+ const commandQueue = yield* Queue.unbounded();
+ const commandReadinessState = yield* Ref.make("pending");
+
+ const commandWorker = Effect.forever(
+ Queue.take(commandQueue).pipe(Effect.flatMap((command) => command.run)),
+ );
+ yield* Effect.forkScoped(commandWorker);
+
+ return {
+ awaitCommandReady: Deferred.await(commandReady),
+ signalCommandReady: Effect.gen(function* () {
+ yield* Ref.set(commandReadinessState, "ready");
+ yield* Deferred.succeed(commandReady, undefined).pipe(Effect.orDie);
+ }),
+ failCommandReady: (error) =>
+ Effect.gen(function* () {
+ yield* Ref.set(commandReadinessState, error);
+ yield* Deferred.fail(commandReady, error).pipe(Effect.orDie);
+ }),
+ enqueueCommand: (effect: Effect.Effect) =>
+ Effect.gen(function* () {
+ const readinessState = yield* Ref.get(commandReadinessState);
+ if (readinessState === "ready") {
+ return yield* effect;
+ }
+ if (readinessState !== "pending") {
+ return yield* readinessState;
+ }
+
+ const result = yield* Deferred.make();
+ yield* Queue.offer(commandQueue, {
+ run: Deferred.await(commandReady).pipe(
+ Effect.flatMap(() => effect),
+ Effect.exit,
+ Effect.flatMap((exit) => settleQueuedCommand(result, exit)),
+ ),
+ });
+ return yield* Deferred.await(result);
+ }),
+ } satisfies CommandGate;
+});
+
+const recordStartupHeartbeat = Effect.gen(function* () {
+ const analytics = yield* AnalyticsService;
+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
+ Effect.map((snapshot) => ({
+ threadCount: snapshot.threads.length,
+ projectCount: snapshot.projects.length,
+ })),
+ Effect.catch((cause) =>
+ Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
+ Effect.as({
+ threadCount: 0,
+ projectCount: 0,
+ }),
+ ),
+ ),
+ );
+
+ yield* analytics.record("server.boot.heartbeat", {
+ threadCount,
+ projectCount,
+ });
+});
+
+const autoBootstrapWelcome = Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig;
+ const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
+ const orchestrationEngine = yield* OrchestrationEngineService;
+ const path = yield* Path.Path;
+
+ let bootstrapProjectId: ProjectId | undefined;
+ let bootstrapThreadId: ThreadId | undefined;
+
+ if (serverConfig.autoBootstrapProjectFromCwd) {
+ yield* Effect.gen(function* () {
+ const snapshot = yield* projectionReadModelQuery.getSnapshot();
+ const existingProject = snapshot.projects.find(
+ (project) => project.workspaceRoot === serverConfig.cwd && project.deletedAt === null,
+ );
+ let nextProjectId: ProjectId;
+ let nextProjectDefaultModel: string;
+
+ if (!existingProject) {
+ const createdAt = new Date().toISOString();
+ nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
+ const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
+ nextProjectDefaultModel = "gpt-5-codex";
+ yield* orchestrationEngine.dispatch({
+ type: "project.create",
+ commandId: CommandId.makeUnsafe(crypto.randomUUID()),
+ projectId: nextProjectId,
+ title: bootstrapProjectTitle,
+ workspaceRoot: serverConfig.cwd,
+ defaultModel: nextProjectDefaultModel,
+ createdAt,
+ });
+ } else {
+ nextProjectId = existingProject.id;
+ nextProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex";
+ }
+
+ const existingThread = snapshot.threads.find(
+ (thread) => thread.projectId === nextProjectId && thread.deletedAt === null,
+ );
+ if (!existingThread) {
+ const createdAt = new Date().toISOString();
+ const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID());
+ yield* orchestrationEngine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.makeUnsafe(crypto.randomUUID()),
+ threadId: createdThreadId,
+ projectId: nextProjectId,
+ title: "New thread",
+ model: nextProjectDefaultModel,
+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ });
+ bootstrapProjectId = nextProjectId;
+ bootstrapThreadId = createdThreadId;
+ } else {
+ bootstrapProjectId = nextProjectId;
+ bootstrapThreadId = existingThread.id;
+ }
+ });
+ }
+
+ const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean);
+ const projectName = segments[segments.length - 1] ?? "project";
+
+ return {
+ cwd: serverConfig.cwd,
+ projectName,
+ ...(bootstrapProjectId ? { bootstrapProjectId } : {}),
+ ...(bootstrapThreadId ? { bootstrapThreadId } : {}),
+ } as const;
+});
+
+const maybeOpenBrowser = Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig;
+ if (serverConfig.noBrowser) {
+ return;
+ }
+ const { openBrowser } = yield* Open;
+ const localUrl = `http://localhost:${serverConfig.port}`;
+ const bindUrl =
+ serverConfig.host && !isWildcardHost(serverConfig.host)
+ ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}`
+ : localUrl;
+ const target = serverConfig.devUrl?.toString() ?? bindUrl;
+
+ yield* openBrowser(target).pipe(
+ Effect.catch(() =>
+ Effect.logInfo("browser auto-open unavailable", {
+ hint: `Open ${target} in your browser.`,
+ }),
+ ),
+ );
+});
+
+const makeServerRuntimeStartup = Effect.gen(function* () {
+ const keybindings = yield* Keybindings;
+ const orchestrationReactor = yield* OrchestrationReactor;
+ const lifecycleEvents = yield* ServerLifecycleEvents;
+
+ const commandGate = yield* makeCommandGate;
+ const httpListening = yield* Deferred.make();
+ const reactorScope = yield* Scope.make("sequential");
+
+ yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void));
+
+ const startup = Effect.gen(function* () {
+ yield* Effect.logDebug("startup phase: starting keybindings runtime");
+ yield* keybindings.start.pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("failed to start keybindings runtime", {
+ path: error.configPath,
+ detail: error.detail,
+ cause: error.cause,
+ }),
+ ),
+ Effect.forkScoped,
+ );
+
+ yield* Effect.logDebug("startup phase: starting orchestration reactors");
+ yield* Scope.provide(orchestrationReactor.start, reactorScope);
+
+ yield* Effect.logDebug("startup phase: preparing welcome payload");
+ const welcome = yield* autoBootstrapWelcome;
+ yield* Effect.logDebug("startup phase: publishing welcome event", {
+ cwd: welcome.cwd,
+ projectName: welcome.projectName,
+ bootstrapProjectId: welcome.bootstrapProjectId,
+ bootstrapThreadId: welcome.bootstrapThreadId,
+ });
+ yield* lifecycleEvents.publish({
+ version: 1,
+ type: "welcome",
+ payload: welcome,
+ });
+ });
+
+ yield* Effect.forkScoped(
+ Effect.gen(function* () {
+ const startupExit = yield* Effect.exit(startup);
+ if (Exit.isFailure(startupExit)) {
+ const error = new ServerRuntimeStartupError({
+ message: "Server runtime startup failed before command readiness.",
+ cause: startupExit.cause,
+ });
+ yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause });
+ yield* commandGate.failCommandReady(error);
+ return;
+ }
+
+ yield* Effect.logInfo("Accepting commands");
+ yield* commandGate.signalCommandReady;
+ yield* Effect.logDebug("startup phase: waiting for http listener");
+ yield* Deferred.await(httpListening);
+ yield* Effect.logDebug("startup phase: publishing ready event");
+ yield* lifecycleEvents.publish({
+ version: 1,
+ type: "ready",
+ payload: { at: new Date().toISOString() },
+ });
+
+ yield* Effect.logDebug("startup phase: recording startup heartbeat");
+ yield* recordStartupHeartbeat;
+ yield* Effect.logDebug("startup phase: browser open check");
+ yield* maybeOpenBrowser;
+ yield* Effect.logDebug("startup phase: complete");
+ }),
+ );
+
+ return {
+ awaitCommandReady: commandGate.awaitCommandReady,
+ markHttpListening: Deferred.succeed(httpListening, undefined).pipe(Effect.orDie),
+ enqueueCommand: commandGate.enqueueCommand,
+ } satisfies ServerRuntimeStartupShape;
+});
+
+export const ServerRuntimeStartupLive = Layer.effect(
+ ServerRuntimeStartup,
+ makeServerRuntimeStartup,
+);
diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts
index 8d8398c7ad..6c122f1af9 100644
--- a/apps/server/src/terminal/Services/Manager.ts
+++ b/apps/server/src/terminal/Services/Manager.ts
@@ -10,6 +10,7 @@ import {
TerminalClearInput,
TerminalCloseInput,
TerminalEvent,
+ TerminalError,
TerminalOpenInput,
TerminalResizeInput,
TerminalRestartInput,
@@ -18,12 +19,8 @@ import {
TerminalWriteInput,
} from "@t3tools/contracts";
import { PtyProcess } from "./PTY";
-import { Effect, Schema, ServiceMap } from "effect";
-
-export class TerminalError extends Schema.TaggedErrorClass()("TerminalError", {
- message: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {}
+import { Effect, ServiceMap } from "effect";
+export { TerminalError };
export interface TerminalSessionState {
threadId: string;
diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts
index 684b005e83..f79bb8e12e 100644
--- a/apps/server/src/workspaceEntries.ts
+++ b/apps/server/src/workspaceEntries.ts
@@ -7,7 +7,9 @@ import {
ProjectEntry,
ProjectSearchEntriesInput,
ProjectSearchEntriesResult,
+ ProjectWriteFileError,
} from "@t3tools/contracts";
+import { Effect, Path } from "effect";
const WORKSPACE_CACHE_TTL_MS = 15_000;
const WORKSPACE_CACHE_MAX_KEYS = 4;
@@ -563,3 +565,40 @@ export async function searchWorkspaceEntries(
truncated: index.truncated || matchedEntryCount > limit,
};
}
+
+function toPosixRelativePath(input: string): string {
+ return input.replaceAll("\\", "/");
+}
+
+export const resolveWorkspaceWritePath = Effect.fn(function* (params: {
+ workspaceRoot: string;
+ relativePath: string;
+}) {
+ const path = yield* Path.Path;
+
+ const normalizedInputPath = params.relativePath.trim();
+ if (path.isAbsolute(normalizedInputPath)) {
+ return yield* new ProjectWriteFileError({
+ message: "Workspace file path must be relative to the project root.",
+ });
+ }
+
+ const absolutePath = path.resolve(params.workspaceRoot, normalizedInputPath);
+ const relativeToRoot = toPosixRelativePath(path.relative(params.workspaceRoot, absolutePath));
+ if (
+ relativeToRoot.length === 0 ||
+ relativeToRoot === "." ||
+ relativeToRoot.startsWith("../") ||
+ relativeToRoot === ".." ||
+ path.isAbsolute(relativeToRoot)
+ ) {
+ return yield* new ProjectWriteFileError({
+ message: "Workspace file path must stay within the project root.",
+ });
+ }
+
+ return {
+ absolutePath,
+ relativePath: relativeToRoot,
+ };
+});
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
new file mode 100644
index 0000000000..cd51322b19
--- /dev/null
+++ b/apps/server/src/ws.ts
@@ -0,0 +1,330 @@
+import { Effect, FileSystem, Layer, Option, Path, Schema, Stream, PubSub, Ref } from "effect";
+import {
+ OrchestrationDispatchCommandError,
+ type OrchestrationEvent,
+ OrchestrationGetFullThreadDiffError,
+ OrchestrationGetSnapshotError,
+ OrchestrationGetTurnDiffError,
+ ORCHESTRATION_WS_METHODS,
+ ProjectSearchEntriesError,
+ ProjectWriteFileError,
+ OrchestrationReplayEventsError,
+ type TerminalEvent,
+ WS_METHODS,
+ WsRpcGroup,
+} from "@t3tools/contracts";
+import { clamp } from "effect/Number";
+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
+import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
+
+import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
+import { ServerConfig } from "./config";
+import { GitCore } from "./git/Services/GitCore";
+import { GitManager } from "./git/Services/GitManager";
+import { Keybindings } from "./keybindings";
+import { Open, resolveAvailableEditors } from "./open";
+import { normalizeDispatchCommand } from "./orchestration/Normalizer";
+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
+import { ProviderHealth } from "./provider/Services/ProviderHealth";
+import { ServerLifecycleEvents } from "./serverLifecycleEvents";
+import { ServerRuntimeStartup } from "./serverRuntimeStartup";
+import { TerminalManager } from "./terminal/Services/Manager";
+import { resolveWorkspaceWritePath, searchWorkspaceEntries } from "./workspaceEntries";
+
+const WsRpcLayer = WsRpcGroup.toLayer(
+ Effect.gen(function* () {
+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
+ const orchestrationEngine = yield* OrchestrationEngineService;
+ const checkpointDiffQuery = yield* CheckpointDiffQuery;
+ const keybindings = yield* Keybindings;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const open = yield* Open;
+ const gitManager = yield* GitManager;
+ const git = yield* GitCore;
+ const terminalManager = yield* TerminalManager;
+ const providerHealth = yield* ProviderHealth;
+ const config = yield* ServerConfig;
+ const lifecycleEvents = yield* ServerLifecycleEvents;
+ const startup = yield* ServerRuntimeStartup;
+
+ return WsRpcGroup.of({
+ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
+ projectionSnapshotQuery.getSnapshot().pipe(
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationGetSnapshotError({
+ message: "Failed to load orchestration snapshot",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) =>
+ Effect.gen(function* () {
+ const normalizedCommand = yield* normalizeDispatchCommand(command);
+ return yield* startup.enqueueCommand(orchestrationEngine.dispatch(normalizedCommand));
+ }).pipe(
+ Effect.mapError((cause) =>
+ Schema.is(OrchestrationDispatchCommandError)(cause)
+ ? cause
+ : new OrchestrationDispatchCommandError({
+ message: "Failed to dispatch orchestration command",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>
+ checkpointDiffQuery.getTurnDiff(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationGetTurnDiffError({
+ message: "Failed to load turn diff",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) =>
+ checkpointDiffQuery.getFullThreadDiff(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationGetFullThreadDiffError({
+ message: "Failed to load full thread diff",
+ cause,
+ }),
+ ),
+ ),
+ [ORCHESTRATION_WS_METHODS.replayEvents]: (input) =>
+ Stream.runCollect(
+ orchestrationEngine.readEvents(
+ clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }),
+ ),
+ ).pipe(
+ Effect.map((events) => Array.from(events)),
+ Effect.mapError(
+ (cause) =>
+ new OrchestrationReplayEventsError({
+ message: "Failed to replay orchestration events",
+ cause,
+ }),
+ ),
+ ),
+ [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const snapshot = yield* orchestrationEngine.getReadModel();
+ const fromSequenceExclusive = snapshot.snapshotSequence;
+ const replayEvents: Array = yield* Stream.runCollect(
+ orchestrationEngine.readEvents(fromSequenceExclusive),
+ ).pipe(
+ Effect.map((events) => Array.from(events)),
+ Effect.catch(() => Effect.succeed([] as Array)),
+ );
+ const replayStream = Stream.fromIterable(replayEvents);
+ const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
+ type SequenceState = {
+ readonly nextSequence: number;
+ readonly pendingBySequence: Map;
+ };
+ const state = yield* Ref.make({
+ nextSequence: fromSequenceExclusive + 1,
+ pendingBySequence: new Map(),
+ });
+
+ return source.pipe(
+ Stream.mapEffect((event) =>
+ Ref.modify(
+ state,
+ ({
+ nextSequence,
+ pendingBySequence,
+ }): [Array, SequenceState] => {
+ if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {
+ return [[], { nextSequence, pendingBySequence }];
+ }
+
+ const updatedPending = new Map(pendingBySequence);
+ updatedPending.set(event.sequence, event);
+
+ const emit: Array = [];
+ let expected = nextSequence;
+ for (;;) {
+ const expectedEvent = updatedPending.get(expected);
+ if (!expectedEvent) {
+ break;
+ }
+ emit.push(expectedEvent);
+ updatedPending.delete(expected);
+ expected += 1;
+ }
+
+ return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
+ },
+ ),
+ ),
+ Stream.flatMap((events) => Stream.fromIterable(events)),
+ );
+ }),
+ ),
+ [WS_METHODS.serverUpsertKeybinding]: (rule) =>
+ Effect.gen(function* () {
+ const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule);
+ return { keybindings: keybindingsConfig, issues: [] };
+ }),
+ [WS_METHODS.projectsSearchEntries]: (input) =>
+ Effect.tryPromise({
+ try: () => searchWorkspaceEntries(input),
+ catch: (cause) =>
+ new ProjectSearchEntriesError({
+ message: "Failed to search workspace entries",
+ cause,
+ }),
+ }),
+ [WS_METHODS.projectsWriteFile]: (input) =>
+ Effect.gen(function* () {
+ const target = yield* resolveWorkspaceWritePath({
+ workspaceRoot: input.cwd,
+ relativePath: input.relativePath,
+ });
+ yield* fileSystem
+ .makeDirectory(path.dirname(target.absolutePath), { recursive: true })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProjectWriteFileError({
+ message: "Failed to prepare workspace path",
+ cause,
+ }),
+ ),
+ );
+ yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProjectWriteFileError({
+ message: "Failed to write workspace file",
+ cause,
+ }),
+ ),
+ );
+ return { relativePath: target.relativePath };
+ }),
+ [WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input),
+ [WS_METHODS.gitStatus]: (input) => gitManager.status(input),
+ [WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd),
+ [WS_METHODS.gitRunStackedAction]: (input) => gitManager.runStackedAction(input),
+ [WS_METHODS.gitResolvePullRequest]: (input) => gitManager.resolvePullRequest(input),
+ [WS_METHODS.gitPreparePullRequestThread]: (input) =>
+ gitManager.preparePullRequestThread(input),
+ [WS_METHODS.gitListBranches]: (input) => git.listBranches(input),
+ [WS_METHODS.gitCreateWorktree]: (input) => git.createWorktree(input),
+ [WS_METHODS.gitRemoveWorktree]: (input) => git.removeWorktree(input),
+ [WS_METHODS.gitCreateBranch]: (input) => git.createBranch(input),
+ [WS_METHODS.gitCheckout]: (input) => Effect.scoped(git.checkoutBranch(input)),
+ [WS_METHODS.gitInit]: (input) => git.initRepo(input),
+ [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input),
+ [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input),
+ [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input),
+ [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input),
+ [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input),
+ [WS_METHODS.terminalClose]: (input) => terminalManager.close(input),
+ [WS_METHODS.subscribeTerminalEvents]: (_input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const pubsub = yield* PubSub.unbounded();
+ const unsubscribe = yield* terminalManager.subscribe((event) => {
+ PubSub.publishUnsafe(pubsub, event);
+ });
+ return Stream.fromPubSub(pubsub).pipe(
+ Stream.ensuring(Effect.sync(() => unsubscribe())),
+ );
+ }),
+ ),
+ [WS_METHODS.subscribeServerConfig]: (_input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const keybindingsConfig = yield* keybindings.loadConfigState;
+ const providers = yield* providerHealth.getStatuses;
+
+ const keybindingsUpdates = keybindings.streamChanges.pipe(
+ Stream.mapEffect((event) =>
+ Effect.succeed({
+ version: 1 as const,
+ type: "keybindingsUpdated" as const,
+ payload: {
+ issues: event.issues,
+ },
+ }),
+ ),
+ );
+ const providerStatuses = Stream.tick("10 seconds").pipe(
+ Stream.mapEffect(() =>
+ Effect.gen(function* () {
+ const providers = yield* providerHealth.getStatuses;
+ return {
+ version: 1 as const,
+ type: "providerStatuses" as const,
+ payload: { providers },
+ };
+ }),
+ ),
+ );
+ return Stream.concat(
+ Stream.make({
+ version: 1 as const,
+ type: "snapshot" as const,
+ config: {
+ cwd: config.cwd,
+ keybindingsConfigPath: config.keybindingsConfigPath,
+ keybindings: keybindingsConfig.keybindings,
+ issues: keybindingsConfig.issues,
+ providers,
+ availableEditors: resolveAvailableEditors(),
+ },
+ }),
+ Stream.merge(keybindingsUpdates, providerStatuses),
+ );
+ }),
+ ),
+ [WS_METHODS.subscribeServerLifecycle]: (_input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const snapshot = yield* lifecycleEvents.snapshot;
+ const snapshotEvents = Array.from(snapshot.events).toSorted(
+ (left, right) => left.sequence - right.sequence,
+ );
+ const liveEvents = lifecycleEvents.stream.pipe(
+ Stream.filter((event) => event.sequence > snapshot.sequence),
+ );
+ return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
+ }),
+ ),
+ });
+ }),
+);
+
+export const websocketRpcRouteLayer = Layer.unwrap(
+ Effect.gen(function* () {
+ const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup).pipe(
+ Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)),
+ );
+ return HttpRouter.add(
+ "GET",
+ "/ws",
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ const config = yield* ServerConfig;
+ if (config.authToken) {
+ const url = HttpServerRequest.toURL(request);
+ if (Option.isNone(url)) {
+ return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 });
+ }
+ const token = url.value.searchParams.get("token");
+ if (token !== config.authToken) {
+ return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 });
+ }
+ }
+ return yield* rpcWebSocketHttpEffect;
+ }),
+ );
+ }),
+);
diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts
deleted file mode 100644
index 9c6adfeba9..0000000000
--- a/apps/server/src/wsServer.test.ts
+++ /dev/null
@@ -1,1835 +0,0 @@
-import * as Http from "node:http";
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect";
-import { describe, expect, it, afterEach, vi } from "vitest";
-import { createServer } from "./wsServer";
-import WebSocket from "ws";
-import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config";
-import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
-
-import {
- DEFAULT_TERMINAL_ID,
- EDITORS,
- EventId,
- ORCHESTRATION_WS_CHANNELS,
- ORCHESTRATION_WS_METHODS,
- ProviderItemId,
- ThreadId,
- TurnId,
- WS_CHANNELS,
- WS_METHODS,
- type WebSocketResponse,
- type ProviderRuntimeEvent,
- type ServerProviderStatus,
- type KeybindingsConfig,
- type ResolvedKeybindingsConfig,
- type WsPushChannel,
- type WsPushMessage,
- type WsPush,
-} from "@t3tools/contracts";
-import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
-import type {
- TerminalClearInput,
- TerminalCloseInput,
- TerminalEvent,
- TerminalOpenInput,
- TerminalResizeInput,
- TerminalSessionSnapshot,
- TerminalWriteInput,
-} from "@t3tools/contracts";
-import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
-import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
-import { SqlClient, SqlError } from "effect/unstable/sql";
-import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
-import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth";
-import { Open, type OpenShape } from "./open";
-import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
-import type { GitCoreShape } from "./git/Services/GitCore.ts";
-import { GitCore } from "./git/Services/GitCore.ts";
-import { GitCommandError, GitManagerError } from "./git/Errors.ts";
-import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
-import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
-
-const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
-const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
-const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
-const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
-
-const defaultOpenService: OpenShape = {
- openBrowser: () => Effect.void,
- openInEditor: () => Effect.void,
-};
-
-const defaultProviderStatuses: ReadonlyArray = [
- {
- provider: "codex",
- status: "ready",
- available: true,
- authStatus: "authenticated",
- checkedAt: "2026-01-01T00:00:00.000Z",
- },
-];
-
-const defaultProviderHealthService: ProviderHealthShape = {
- getStatuses: Effect.succeed(defaultProviderStatuses),
-};
-
-class MockTerminalManager implements TerminalManagerShape {
- private readonly sessions = new Map();
- private readonly listeners = new Set<(event: TerminalEvent) => void>();
-
- private key(threadId: string, terminalId: string): string {
- return `${threadId}\u0000${terminalId}`;
- }
-
- emitEvent(event: TerminalEvent): void {
- for (const listener of this.listeners) {
- listener(event);
- }
- }
-
- subscriptionCount(): number {
- return this.listeners.size;
- }
-
- readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) =>
- Effect.sync(() => {
- const now = new Date().toISOString();
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- const snapshot: TerminalSessionSnapshot = {
- threadId: input.threadId,
- terminalId,
- cwd: input.cwd,
- status: "running",
- pid: 4242,
- history: "",
- exitCode: null,
- exitSignal: null,
- updatedAt: now,
- };
- this.sessions.set(this.key(input.threadId, terminalId), snapshot);
- queueMicrotask(() => {
- this.emitEvent({
- type: "started",
- threadId: input.threadId,
- terminalId,
- createdAt: now,
- snapshot,
- });
- });
- return snapshot;
- });
-
- readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) =>
- Effect.sync(() => {
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- const existing = this.sessions.get(this.key(input.threadId, terminalId));
- if (!existing) {
- throw new Error(`Unknown terminal thread: ${input.threadId}`);
- }
- queueMicrotask(() => {
- this.emitEvent({
- type: "output",
- threadId: input.threadId,
- terminalId,
- createdAt: new Date().toISOString(),
- data: input.data,
- });
- });
- });
-
- readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void;
-
- readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) =>
- Effect.sync(() => {
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- queueMicrotask(() => {
- this.emitEvent({
- type: "cleared",
- threadId: input.threadId,
- terminalId,
- createdAt: new Date().toISOString(),
- });
- });
- });
-
- readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) =>
- Effect.sync(() => {
- const now = new Date().toISOString();
- const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
- const snapshot: TerminalSessionSnapshot = {
- threadId: input.threadId,
- terminalId,
- cwd: input.cwd,
- status: "running",
- pid: 5252,
- history: "",
- exitCode: null,
- exitSignal: null,
- updatedAt: now,
- };
- this.sessions.set(this.key(input.threadId, terminalId), snapshot);
- queueMicrotask(() => {
- this.emitEvent({
- type: "restarted",
- threadId: input.threadId,
- terminalId,
- createdAt: now,
- snapshot,
- });
- });
- return snapshot;
- });
-
- readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) =>
- Effect.sync(() => {
- if (input.terminalId) {
- this.sessions.delete(this.key(input.threadId, input.terminalId));
- return;
- }
- for (const key of this.sessions.keys()) {
- if (key.startsWith(`${input.threadId}\u0000`)) {
- this.sessions.delete(key);
- }
- }
- });
-
- readonly subscribe: TerminalManagerShape["subscribe"] = (listener) =>
- Effect.sync(() => {
- this.listeners.add(listener);
- return () => {
- this.listeners.delete(listener);
- };
- });
-
- readonly dispose: TerminalManagerShape["dispose"] = Effect.void;
-}
-
-// ---------------------------------------------------------------------------
-// WebSocket test harness
-//
-// Incoming messages are split into two channels:
-// - pushChannel: server push envelopes (type === "push")
-// - responseChannel: request/response envelopes (have an "id" field)
-//
-// This means sendRequest never has to skip push messages and waitForPush
-// never has to skip response messages, eliminating a class of ordering bugs.
-// ---------------------------------------------------------------------------
-
-interface MessageChannel {
- queue: T[];
- waiters: Array<{
- resolve: (value: T) => void;
- reject: (error: Error) => void;
- timeoutId: ReturnType | null;
- }>;
-}
-
-interface SocketChannels {
- push: MessageChannel;
- response: MessageChannel;
-}
-
-const channelsBySocket = new WeakMap();
-
-function enqueue(channel: MessageChannel, item: T) {
- const waiter = channel.waiters.shift();
- if (waiter) {
- if (waiter.timeoutId !== null) clearTimeout(waiter.timeoutId);
- waiter.resolve(item);
- return;
- }
- channel.queue.push(item);
-}
-
-function dequeue(channel: MessageChannel, timeoutMs: number): Promise {
- const queued = channel.queue.shift();
- if (queued !== undefined) {
- return Promise.resolve(queued);
- }
-
- return new Promise((resolve, reject) => {
- const waiter = {
- resolve,
- reject,
- timeoutId: setTimeout(() => {
- const index = channel.waiters.indexOf(waiter);
- if (index >= 0) channel.waiters.splice(index, 1);
- reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`));
- }, timeoutMs) as ReturnType,
- };
- channel.waiters.push(waiter);
- });
-}
-
-function isWsPushEnvelope(message: unknown): message is WsPush {
- if (typeof message !== "object" || message === null) return false;
- if (!("type" in message) || !("channel" in message)) return false;
- return (message as { type?: unknown }).type === "push";
-}
-
-function asWebSocketResponse(message: unknown): WebSocketResponse | null {
- if (typeof message !== "object" || message === null) return null;
- if (!("id" in message)) return null;
- const id = (message as { id?: unknown }).id;
- if (typeof id !== "string") return null;
- return message as WebSocketResponse;
-}
-
-function connectWsOnce(port: number, token?: string): Promise {
- return new Promise((resolve, reject) => {
- const query = token ? `?token=${encodeURIComponent(token)}` : "";
- const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`);
- const channels: SocketChannels = {
- push: { queue: [], waiters: [] },
- response: { queue: [], waiters: [] },
- };
- channelsBySocket.set(ws, channels);
-
- ws.on("message", (raw) => {
- const parsed = JSON.parse(String(raw));
- if (isWsPushEnvelope(parsed)) {
- enqueue(channels.push, parsed);
- } else {
- const response = asWebSocketResponse(parsed);
- if (response) {
- enqueue(channels.response, response);
- }
- }
- });
-
- ws.once("open", () => resolve(ws));
- ws.once("error", () => reject(new Error("WebSocket connection failed")));
- });
-}
-
-async function connectWs(port: number, token?: string, attempts = 5): Promise {
- let lastError: unknown = new Error("WebSocket connection failed");
-
- for (let attempt = 0; attempt < attempts; attempt += 1) {
- try {
- return await connectWsOnce(port, token);
- } catch (error) {
- lastError = error;
- if (attempt < attempts - 1) {
- await new Promise((resolve) => setTimeout(resolve, 25));
- }
- }
- }
-
- throw lastError;
-}
-
-/** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */
-async function connectAndAwaitWelcome(
- port: number,
- token?: string,
-): Promise<[WebSocket, WsPushMessage]> {
- const ws = await connectWs(port, token);
- const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome);
- return [ws, welcome];
-}
-
-async function sendRequest(
- ws: WebSocket,
- method: string,
- params?: unknown,
-): Promise {
- const channels = channelsBySocket.get(ws);
- if (!channels) throw new Error("WebSocket not initialized");
-
- const id = crypto.randomUUID();
- const body =
- method === ORCHESTRATION_WS_METHODS.dispatchCommand
- ? { _tag: method, command: params }
- : params && typeof params === "object" && !Array.isArray(params)
- ? { _tag: method, ...(params as Record) }
- : { _tag: method };
- ws.send(JSON.stringify({ id, body }));
-
- // Response channel only contains responses — no push filtering needed
- while (true) {
- const response = await dequeue(channels.response, 60_000);
- if (response.id === id || response.id === "unknown") {
- return response;
- }
- }
-}
-
-async function waitForPush(
- ws: WebSocket,
- channel: C,
- predicate?: (push: WsPushMessage) => boolean,
- maxMessages = 120,
- idleTimeoutMs = 5_000,
-): Promise> {
- const channels = channelsBySocket.get(ws);
- if (!channels) throw new Error("WebSocket not initialized");
-
- for (let remaining = maxMessages; remaining > 0; remaining--) {
- const push = await dequeue(channels.push, idleTimeoutMs);
- if (push.channel !== channel) continue;
- const typed = push as WsPushMessage;
- if (!predicate || predicate(typed)) return typed;
- }
- throw new Error(`Timed out waiting for push on ${channel}`);
-}
-
-async function rewriteKeybindingsAndWaitForPush(
- ws: WebSocket,
- keybindingsPath: string,
- contents: string,
- predicate: (push: WsPushMessage) => boolean,
- attempts = 3,
-): Promise> {
- let lastError: unknown;
- for (let attempt = 0; attempt < attempts; attempt++) {
- fs.writeFileSync(keybindingsPath, contents, "utf8");
- try {
- return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000);
- } catch (error) {
- lastError = error;
- }
- }
- throw lastError;
-}
-
-async function requestPath(
- port: number,
- requestPath: string,
-): Promise<{ statusCode: number; body: string }> {
- return new Promise((resolve, reject) => {
- const req = Http.request(
- {
- hostname: "127.0.0.1",
- port,
- path: requestPath,
- method: "GET",
- },
- (res) => {
- const chunks: Buffer[] = [];
- res.on("data", (chunk) => {
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
- });
- res.on("end", () => {
- resolve({
- statusCode: res.statusCode ?? 0,
- body: Buffer.concat(chunks).toString("utf8"),
- });
- });
- },
- );
- req.once("error", reject);
- req.end();
- });
-}
-
-function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
- const resolved: Array = [];
- for (const binding of bindings) {
- const compiled = compileResolvedKeybindingRule(binding);
- if (!compiled) {
- throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`);
- }
- resolved.push(compiled);
- }
- return resolved;
-}
-
-const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
-const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id));
-
-function expectAvailableEditors(value: unknown): void {
- expect(Array.isArray(value)).toBe(true);
- for (const editorId of value as unknown[]) {
- expect(typeof editorId).toBe("string");
- expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true);
- }
-}
-
-function ensureParentDir(filePath: string): void {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
-}
-
-function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) {
- return Effect.runSync(
- deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)),
- );
-}
-
-describe("WebSocket Server", () => {
- let server: Http.Server | null = null;
- let serverScope: Scope.Closeable | null = null;
- const connections: WebSocket[] = [];
- const tempDirs: string[] = [];
-
- function makeTempDir(prefix: string): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
- tempDirs.push(dir);
- return dir;
- }
-
- async function createTestServer(
- options: {
- persistenceLayer?: Layer.Layer<
- SqlClient.SqlClient,
- SqlError.SqlError | MigrationError | PlatformError.PlatformError
- >;
- cwd?: string;
- autoBootstrapProjectFromCwd?: boolean;
- logWebSocketEvents?: boolean;
- devUrl?: string;
- authToken?: string;
- baseDir?: string;
- staticDir?: string;
- providerLayer?: Layer.Layer;
- providerHealth?: ProviderHealthShape;
- open?: OpenShape;
- gitManager?: GitManagerShape;
- gitCore?: Pick;
- terminalManager?: TerminalManagerShape;
- } = {},
- ): Promise {
- if (serverScope) {
- throw new Error("Test server is already running");
- }
-
- const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-");
- const devUrl = options.devUrl ? new URL(options.devUrl) : undefined;
- const derivedPaths = deriveServerPathsSync(baseDir, devUrl);
- const scope = await Effect.runPromise(Scope.make("sequential"));
- const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
- const providerLayer = options.providerLayer ?? makeServerProviderLayer();
- const providerHealthLayer = Layer.succeed(
- ProviderHealth,
- options.providerHealth ?? defaultProviderHealthService,
- );
- const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
- const serverConfigLayer = Layer.succeed(ServerConfig, {
- mode: "web",
- port: 0,
- host: undefined,
- cwd: options.cwd ?? "/test/project",
- baseDir,
- ...derivedPaths,
- staticDir: options.staticDir,
- devUrl,
- noBrowser: true,
- authToken: options.authToken,
- autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
- logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl),
- } satisfies ServerConfigShape);
- const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
- const runtimeOverrides = Layer.mergeAll(
- options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty,
- options.gitCore
- ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
- : Layer.empty,
- options.terminalManager
- ? Layer.succeed(TerminalManager, options.terminalManager)
- : Layer.empty,
- );
-
- const runtimeLayer = Layer.merge(
- Layer.merge(
- makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
- infrastructureLayer,
- ),
- runtimeOverrides,
- );
- const dependenciesLayer = Layer.empty.pipe(
- Layer.provideMerge(runtimeLayer),
- Layer.provideMerge(providerHealthLayer),
- Layer.provideMerge(openLayer),
- Layer.provideMerge(serverConfigLayer),
- Layer.provideMerge(AnalyticsService.layerTest),
- Layer.provideMerge(NodeServices.layer),
- );
- const runtimeServices = await Effect.runPromise(
- Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),
- );
-
- try {
- const runtime = await Effect.runPromise(
- createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)),
- );
- serverScope = scope;
- return runtime;
- } catch (error) {
- await Effect.runPromise(Scope.close(scope, Exit.void));
- throw error;
- }
- }
-
- async function closeTestServer() {
- if (!serverScope) return;
- const scope = serverScope;
- serverScope = null;
- await Effect.runPromise(Scope.close(scope, Exit.void));
- }
-
- afterEach(async () => {
- for (const ws of connections) {
- ws.close();
- }
- connections.length = 0;
- await closeTestServer();
- server = null;
- for (const dir of tempDirs.splice(0, tempDirs.length)) {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- vi.restoreAllMocks();
- });
-
- it("sends welcome message on connect", async () => {
- server = await createTestServer({ cwd: "/test/project" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [ws, welcome] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- expect(welcome.type).toBe("push");
- expect(welcome.data).toEqual({
- cwd: "/test/project",
- projectName: "project",
- });
- });
-
- it("serves persisted attachments from stateDir", async () => {
- const baseDir = makeTempDir("t3code-state-attachments-");
- const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
- const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png");
- fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
- fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));
-
- server = await createTestServer({ cwd: "/test/project", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`);
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toContain("image/png");
- const bytes = Buffer.from(await response.arrayBuffer());
- expect(bytes).toEqual(Buffer.from("hello-attachment"));
- });
-
- it("serves persisted attachments for URL-encoded paths", async () => {
- const baseDir = makeTempDir("t3code-state-attachments-encoded-");
- const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
- const attachmentPath = path.join(
- attachmentsDir,
- "thread%20folder",
- "message%20folder",
- "file%20name.png",
- );
- fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
- fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment"));
-
- server = await createTestServer({ cwd: "/test/project", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await fetch(
- `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`,
- );
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toContain("image/png");
- const bytes = Buffer.from(await response.arrayBuffer());
- expect(bytes).toEqual(Buffer.from("hello-encoded-attachment"));
- });
-
- it("serves static index for root path", async () => {
- const baseDir = makeTempDir("t3code-state-static-root-");
- const staticDir = makeTempDir("t3code-static-root-");
- fs.writeFileSync(path.join(staticDir, "index.html"), "static-root
", "utf8");
-
- server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await fetch(`http://127.0.0.1:${port}/`);
- expect(response.status).toBe(200);
- expect(await response.text()).toContain("static-root");
- });
-
- it("rejects static path traversal attempts", async () => {
- const baseDir = makeTempDir("t3code-state-static-traversal-");
- const staticDir = makeTempDir("t3code-static-traversal-");
- fs.writeFileSync(path.join(staticDir, "index.html"), "safe
", "utf8");
-
- server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const response = await requestPath(port, "/..%2f..%2fetc/passwd");
- expect(response.statusCode).toBe(400);
- expect(response.body).toBe("Invalid static file path");
- });
-
- it("bootstraps the cwd project on startup when enabled", async () => {
- server = await createTestServer({
- cwd: "/test/bootstrap-workspace",
- autoBootstrapProjectFromCwd: true,
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [ws, welcome] = await connectAndAwaitWelcome(port);
- connections.push(ws);
- expect(welcome.data).toEqual(
- expect.objectContaining({
- cwd: "/test/bootstrap-workspace",
- projectName: "bootstrap-workspace",
- bootstrapProjectId: expect.any(String),
- bootstrapThreadId: expect.any(String),
- }),
- );
-
- const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
- expect(snapshotResponse.error).toBeUndefined();
- const snapshot = snapshotResponse.result as {
- projects: Array<{
- id: string;
- workspaceRoot: string;
- title: string;
- defaultModel: string | null;
- }>;
- threads: Array<{
- id: string;
- projectId: string;
- title: string;
- model: string;
- branch: string | null;
- worktreePath: string | null;
- }>;
- };
- const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId;
- const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId;
- expect(bootstrapProjectId).toBeDefined();
- expect(bootstrapThreadId).toBeDefined();
-
- expect(snapshot.projects).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- id: bootstrapProjectId,
- workspaceRoot: "/test/bootstrap-workspace",
- title: "bootstrap-workspace",
- defaultModel: "gpt-5-codex",
- }),
- ]),
- );
- expect(snapshot.threads).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- id: bootstrapThreadId,
- projectId: bootstrapProjectId,
- title: "New thread",
- model: "gpt-5-codex",
- branch: null,
- worktreePath: null,
- }),
- ]),
- );
- });
-
- it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
- const baseDir = makeTempDir("t3code-state-bootstrap-existing-");
- const { dbPath } = deriveServerPathsSync(baseDir, undefined);
- const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe(
- Layer.provide(NodeServices.layer),
- );
- const cwd = "/test/bootstrap-existing";
-
- server = await createTestServer({
- cwd,
- baseDir,
- persistenceLayer,
- autoBootstrapProjectFromCwd: true,
- });
- let addr = server.address();
- let port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port);
- connections.push(firstWs);
- const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string })
- .bootstrapProjectId;
- const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string })
- .bootstrapThreadId;
- expect(firstBootstrapProjectId).toBeDefined();
- expect(firstBootstrapThreadId).toBeDefined();
-
- firstWs.close();
- await closeTestServer();
- server = null;
-
- server = await createTestServer({
- cwd,
- baseDir,
- persistenceLayer,
- autoBootstrapProjectFromCwd: true,
- });
- addr = server.address();
- port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port);
- connections.push(secondWs);
- expect(secondWelcome.data).toEqual(
- expect.objectContaining({
- cwd,
- projectName: "bootstrap-existing",
- bootstrapProjectId: firstBootstrapProjectId,
- bootstrapThreadId: firstBootstrapThreadId,
- }),
- );
- });
-
- it("logs outbound websocket push events in dev mode", async () => {
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
- // Keep test output clean while verifying websocket logs.
- });
-
- server = await createTestServer({
- cwd: "/test/project",
- devUrl: "http://localhost:5173",
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
- expect(port).toBeGreaterThan(0);
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- expect(
- logSpy.mock.calls.some(([message]) => {
- if (typeof message !== "string") return false;
- return (
- message.includes("[ws]") &&
- message.includes("outgoing push") &&
- message.includes(`channel="${WS_CHANNELS.serverWelcome}"`)
- );
- }),
- ).toBe(true);
- });
-
- it("responds to server.getConfig", async () => {
- const baseDir = makeTempDir("t3code-state-get-config-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(keybindingsPath, "[]", "utf8");
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
- });
-
- it("bootstraps default keybindings file when missing", async () => {
- const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- expect(fs.existsSync(keybindingsPath)).toBe(false);
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
-
- const persistedConfig = JSON.parse(
- fs.readFileSync(keybindingsPath, "utf8"),
- ) as KeybindingsConfig;
- expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
- });
-
- it("falls back to defaults and reports malformed keybindings config issues", async () => {
- const baseDir = makeTempDir("t3code-state-malformed-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
- issues: [
- {
- kind: "keybindings.malformed-config",
- message: expect.stringContaining("expected JSON array"),
- },
- ],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
- expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
- });
-
- it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {
- const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(
- keybindingsPath,
- JSON.stringify([
- { key: "mod+j", command: "terminal.toggle" },
- { key: "mod+shift+d+o", command: "terminal.new" },
- { key: "mod+x", command: "not-a-real-command" },
- ]),
- "utf8",
- );
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- const result = response.result as {
- cwd: string;
- keybindingsConfigPath: string;
- keybindings: ResolvedKeybindingsConfig;
- issues: Array<{ kind: string; index?: number; message: string }>;
- providers: ReadonlyArray;
- availableEditors: unknown;
- };
- expect(result.cwd).toBe("/my/workspace");
- expect(result.keybindingsConfigPath).toBe(keybindingsPath);
- expect(result.issues).toEqual([
- {
- kind: "keybindings.invalid-entry",
- index: 1,
- message: expect.any(String),
- },
- {
- kind: "keybindings.invalid-entry",
- index: 2,
- message: expect.any(String),
- },
- ]);
- expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
- expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
- expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
- expect(result.providers).toEqual(defaultProviderStatuses);
- expectAvailableEditors(result.availableEditors);
- });
-
- it("pushes server.configUpdated issues when keybindings file changes", async () => {
- const baseDir = makeTempDir("t3code-state-keybindings-watch-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(keybindingsPath, "[]", "utf8");
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const malformedPush = await rewriteKeybindingsAndWaitForPush(
- ws,
- keybindingsPath,
- "{ not-json",
- (push) =>
- Array.isArray(push.data.issues) &&
- Boolean(push.data.issues[0]) &&
- push.data.issues[0]!.kind === "keybindings.malformed-config",
- );
- expect(malformedPush.data).toEqual({
- issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
- providers: defaultProviderStatuses,
- });
-
- const successPush = await rewriteKeybindingsAndWaitForPush(
- ws,
- keybindingsPath,
- "[]",
- (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0,
- );
- expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses });
- });
-
- it("routes shell.openInEditor through the injected open service", async () => {
- const openCalls: Array<{ cwd: string; editor: string }> = [];
- const openService: OpenShape = {
- openBrowser: () => Effect.void,
- openInEditor: (input) => {
- openCalls.push({ cwd: input.cwd, editor: input.editor });
- return Effect.void;
- },
- };
-
- server = await createTestServer({ cwd: "/my/workspace", open: openService });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, {
- cwd: "/my/workspace",
- editor: "cursor",
- });
- expect(response.error).toBeUndefined();
- expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]);
- });
-
- it("reads keybindings from the configured state directory", async () => {
- const baseDir = makeTempDir("t3code-state-keybindings-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(
- keybindingsPath,
- JSON.stringify([
- { key: "cmd+j", command: "terminal.toggle" },
- { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
- { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
- ]),
- "utf8",
- );
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(response.error).toBeUndefined();
- const persistedConfig = JSON.parse(
- fs.readFileSync(keybindingsPath, "utf8"),
- ) as KeybindingsConfig;
- expect(response.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: compileKeybindings(persistedConfig),
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- });
- expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
- });
-
- it("upserts keybinding rules and updates cached server config", async () => {
- const baseDir = makeTempDir("t3code-state-upsert-keybinding-");
- const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
- ensureParentDir(keybindingsPath);
- fs.writeFileSync(
- keybindingsPath,
- JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
- "utf8",
- );
-
- server = await createTestServer({ cwd: "/my/workspace", baseDir });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, {
- key: "mod+shift+r",
- command: "script.run-tests.run",
- });
- expect(upsertResponse.error).toBeUndefined();
- const persistedConfig = JSON.parse(
- fs.readFileSync(keybindingsPath, "utf8"),
- ) as KeybindingsConfig;
- const persistedCommands = new Set(persistedConfig.map((entry) => entry.command));
- for (const defaultRule of DEFAULT_KEYBINDINGS) {
- expect(persistedCommands.has(defaultRule.command)).toBe(true);
- }
- expect(persistedCommands.has("script.run-tests.run")).toBe(true);
- expect(upsertResponse.result).toEqual({
- keybindings: compileKeybindings(persistedConfig),
- issues: [],
- });
-
- const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
- expect(configResponse.error).toBeUndefined();
- expect(configResponse.result).toEqual({
- cwd: "/my/workspace",
- keybindingsConfigPath: keybindingsPath,
- keybindings: compileKeybindings(persistedConfig),
- issues: [],
- providers: defaultProviderStatuses,
- availableEditors: expect.any(Array),
- });
- expectAvailableEditors(
- (configResponse.result as { availableEditors: unknown }).availableEditors,
- );
- });
-
- it("returns error for unknown methods", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, "nonexistent.method");
- expect(response.error).toBeDefined();
- expect(response.error!.message).toContain("Invalid request format");
- });
-
- it("returns error when requesting turn diff for unknown thread", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
- threadId: "thread-missing",
- fromTurnCount: 1,
- toTurnCount: 2,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
- });
-
- it("returns error when requesting turn diff with an inverted range", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
- threadId: "thread-any",
- fromTurnCount: 2,
- toTurnCount: 1,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain(
- "fromTurnCount must be less than or equal to toTurnCount",
- );
- });
-
- it("returns error when requesting full thread diff for unknown thread", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
- threadId: "thread-missing",
- toTurnCount: 2,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
- });
-
- it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const workspaceRoot = makeTempDir("t3code-ws-diff-project-");
- const createdAt = new Date().toISOString();
- const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "project.create",
- commandId: "cmd-diff-project-create",
- projectId: "project-diff",
- title: "Diff Project",
- workspaceRoot,
- defaultModel: "gpt-5-codex",
- createdAt,
- });
- expect(createProjectResponse.error).toBeUndefined();
- const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "thread.create",
- commandId: "cmd-diff-thread-create",
- threadId: "thread-diff",
- projectId: "project-diff",
- title: "Diff Thread",
- model: "gpt-5-codex",
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- createdAt,
- });
- expect(createThreadResponse.error).toBeUndefined();
-
- const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
- threadId: "thread-diff",
- fromTurnCount: 0,
- toTurnCount: 1,
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("exceeds current turn count");
- });
-
- it("keeps orchestration domain push behavior for provider runtime events", async () => {
- const runtimeEventPubSub = Effect.runSync(PubSub.unbounded());
- const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
- Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
- };
- const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
- const providerService: ProviderServiceShape = {
- startSession: (threadId) =>
- Effect.succeed({
- provider: "codex",
- status: "ready",
- runtimeMode: "full-access",
- threadId,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- }),
- sendTurn: ({ threadId }) =>
- Effect.succeed({
- threadId,
- turnId: asTurnId("provider-turn-1"),
- }),
- interruptTurn: () => unsupported(),
- respondToRequest: () => unsupported(),
- respondToUserInput: () => unsupported(),
- stopSession: () => unsupported(),
- listSessions: () => Effect.succeed([]),
- getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
- rollbackConversation: () => unsupported(),
- streamEvents: Stream.fromPubSub(runtimeEventPubSub),
- };
- const providerLayer = Layer.succeed(ProviderService, providerService);
-
- server = await createTestServer({
- cwd: "/test",
- providerLayer,
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const workspaceRoot = makeTempDir("t3code-ws-project-");
- const createdAt = new Date().toISOString();
- const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "project.create",
- commandId: "cmd-ws-project-create",
- projectId: "project-1",
- title: "WS Project",
- workspaceRoot,
- defaultModel: "gpt-5-codex",
- createdAt,
- });
- expect(createProjectResponse.error).toBeUndefined();
- const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "thread.create",
- commandId: "cmd-ws-runtime-thread-create",
- threadId: "thread-1",
- projectId: "project-1",
- title: "Thread 1",
- model: "gpt-5-codex",
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- createdAt,
- });
- expect(createThreadResponse.error).toBeUndefined();
-
- const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
- type: "thread.turn.start",
- commandId: "cmd-ws-runtime-turn-start",
- threadId: "thread-1",
- message: {
- messageId: "msg-ws-runtime-1",
- role: "user",
- text: "hello",
- attachments: [],
- },
- assistantDeliveryMode: "streaming",
- runtimeMode: "approval-required",
- interactionMode: "default",
- createdAt,
- });
- expect(startTurnResponse.error).toBeUndefined();
-
- await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
- const event = push.data as { type?: string };
- return event.type === "thread.session-set";
- });
-
- emitRuntimeEvent({
- type: "content.delta",
- eventId: asEventId("evt-ws-runtime-message-delta"),
- provider: "codex",
- threadId: asThreadId("thread-1"),
- createdAt: new Date().toISOString(),
- turnId: asTurnId("turn-1"),
- itemId: asProviderItemId("item-1"),
- payload: {
- streamKind: "assistant_text",
- delta: "hello from runtime",
- },
- } as unknown as ProviderRuntimeEvent);
-
- const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
- const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
- return (
- event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1"
- );
- });
-
- const domainEvent = domainPush.data as {
- type: string;
- payload: { messageId: string; text: string };
- };
- expect(domainEvent.type).toBe("thread.message-sent");
- expect(domainEvent.payload.messageId).toBe("assistant:item-1");
- expect(domainEvent.payload.text).toBe("hello from runtime");
- });
-
- it("routes terminal RPC methods and broadcasts terminal events", async () => {
- const cwd = makeTempDir("t3code-ws-terminal-cwd-");
- const terminalManager = new MockTerminalManager();
- server = await createTestServer({
- cwd: "/test",
- terminalManager,
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const open = await sendRequest(ws, WS_METHODS.terminalOpen, {
- threadId: "thread-1",
- cwd,
- cols: 100,
- rows: 24,
- });
- expect(open.error).toBeUndefined();
- expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
- expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID);
-
- const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
- threadId: "thread-1",
- data: "echo hello\n",
- });
- expect(write.error).toBeUndefined();
-
- const resize = await sendRequest(ws, WS_METHODS.terminalResize, {
- threadId: "thread-1",
- cols: 120,
- rows: 30,
- });
- expect(resize.error).toBeUndefined();
-
- const clear = await sendRequest(ws, WS_METHODS.terminalClear, {
- threadId: "thread-1",
- });
- expect(clear.error).toBeUndefined();
-
- const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
- threadId: "thread-1",
- cwd,
- cols: 120,
- rows: 30,
- });
- expect(restart.error).toBeUndefined();
-
- const close = await sendRequest(ws, WS_METHODS.terminalClose, {
- threadId: "thread-1",
- deleteHistory: true,
- });
- expect(close.error).toBeUndefined();
-
- const manualEvent: TerminalEvent = {
- type: "output",
- threadId: "thread-1",
- terminalId: DEFAULT_TERMINAL_ID,
- createdAt: new Date().toISOString(),
- data: "manual test output\n",
- };
- terminalManager.emitEvent(manualEvent);
-
- const push = await waitForPush(
- ws,
- WS_CHANNELS.terminalEvent,
- (candidate) => (candidate.data as TerminalEvent).type === "output",
- );
- expect(push.type).toBe("push");
- expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
- });
-
- it("detaches terminal event listener on stop for injected manager", async () => {
- const terminalManager = new MockTerminalManager();
- server = await createTestServer({
- cwd: "/test",
- terminalManager,
- });
-
- expect(terminalManager.subscriptionCount()).toBe(1);
-
- await closeTestServer();
- server = null;
-
- expect(terminalManager.subscriptionCount()).toBe(0);
- });
-
- it("returns validation errors for invalid terminal open params", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.terminalOpen, {
- threadId: "",
- cwd: "",
- cols: 1,
- rows: 1,
- });
- expect(response.error).toBeDefined();
- });
-
- it("handles invalid JSON gracefully", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- // Send garbage
- ws.send("not json at all");
-
- // Error response goes to the response channel
- const channels = channelsBySocket.get(ws)!;
- let response: WebSocketResponse | null = null;
- for (let attempt = 0; attempt < 5; attempt += 1) {
- const message = await dequeue(channels.response, 5_000);
- if (message.id === "unknown") {
- response = message;
- break;
- }
- if (message.error) {
- response = message;
- break;
- }
- }
- expect(response).toBeDefined();
- expect(response!.error).toBeDefined();
- expect(response!.error!.message).toContain("Invalid request format");
- });
-
- it("catches websocket message handler rejections and keeps the socket usable", async () => {
- const unhandledRejections: unknown[] = [];
- const onUnhandledRejection = (reason: unknown) => {
- unhandledRejections.push(reason);
- };
- process.on("unhandledRejection", onUnhandledRejection);
-
- const brokenOpenService: OpenShape = {
- openBrowser: () => Effect.void,
- openInEditor: () =>
- Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)),
- };
-
- try {
- server = await createTestServer({ cwd: "/test", open: brokenOpenService });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- ws.send(
- JSON.stringify({
- id: "req-broken-open",
- body: {
- _tag: WS_METHODS.shellOpenInEditor,
- cwd: "/tmp",
- editor: "cursor",
- },
- }),
- );
-
- await new Promise((resolve) => setTimeout(resolve, 50));
- expect(unhandledRejections).toHaveLength(0);
-
- const workspace = makeTempDir("t3code-ws-handler-still-usable-");
- fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8");
- const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
- cwd: workspace,
- query: "file",
- limit: 5,
- });
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual(
- expect.objectContaining({
- entries: expect.arrayContaining([
- expect.objectContaining({
- path: "file.txt",
- kind: "file",
- }),
- ]),
- }),
- );
- } finally {
- process.off("unhandledRejection", onUnhandledRejection);
- }
- });
-
- it("returns errors for removed projects CRUD methods", async () => {
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
- expect(listResponse.result).toBeUndefined();
- expect(listResponse.error?.message).toContain("Invalid request format");
-
- const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, {
- cwd: "/tmp/project-a",
- });
- expect(addResponse.result).toBeUndefined();
- expect(addResponse.error?.message).toContain("Invalid request format");
-
- const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, {
- id: "project-a",
- });
- expect(removeResponse.result).toBeUndefined();
- expect(removeResponse.error?.message).toContain("Invalid request format");
- });
-
- it("supports projects.searchEntries", async () => {
- const workspace = makeTempDir("t3code-ws-workspace-entries-");
- fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });
- fs.writeFileSync(
- path.join(workspace, "src", "components", "Composer.tsx"),
- "export {};",
- "utf8",
- );
- fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
- fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
- fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
-
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
- cwd: workspace,
- query: "comp",
- limit: 10,
- });
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- entries: expect.arrayContaining([
- expect.objectContaining({ path: "src/components", kind: "directory" }),
- expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }),
- ]),
- truncated: false,
- });
- });
-
- it("supports projects.writeFile within the workspace root", async () => {
- const workspace = makeTempDir("t3code-ws-write-file-");
-
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
- cwd: workspace,
- relativePath: "plans/effect-rpc.md",
- contents: "# Plan\n\n- step 1\n",
- });
-
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual({
- relativePath: "plans/effect-rpc.md",
- });
- expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe(
- "# Plan\n\n- step 1\n",
- );
- });
-
- it("rejects projects.writeFile paths outside the workspace root", async () => {
- const workspace = makeTempDir("t3code-ws-write-file-reject-");
-
- server = await createTestServer({ cwd: "/test" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
- cwd: workspace,
- relativePath: "../escape.md",
- contents: "# no\n",
- });
-
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain(
- "Workspace file path must stay within the project root.",
- );
- expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);
- });
-
- it("routes git core methods over websocket", async () => {
- const listBranches = vi.fn(() =>
- Effect.succeed({
- branches: [],
- isRepo: false,
- hasOriginRemote: false,
- }),
- );
- const initRepo = vi.fn(() => Effect.void);
- const pullCurrentBranch = vi.fn(() =>
- Effect.fail(
- new GitCommandError({
- operation: "GitCore.test.pullCurrentBranch",
- detail: "No upstream configured",
- command: "git pull",
- cwd: "/repo/path",
- }),
- ),
- );
-
- server = await createTestServer({
- cwd: "/test",
- gitCore: {
- listBranches,
- initRepo,
- pullCurrentBranch,
- },
- });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });
- expect(listResponse.error).toBeUndefined();
- expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false });
- expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" });
-
- const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });
- expect(initResponse.error).toBeUndefined();
- expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" });
-
- const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" });
- expect(pullResponse.result).toBeUndefined();
- expect(pullResponse.error?.message).toContain("No upstream configured");
- expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
- });
-
- it("supports git.status over websocket", async () => {
- const statusResult = {
- branch: "feature/test",
- hasWorkingTreeChanges: true,
- workingTree: {
- files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
- insertions: 7,
- deletions: 2,
- },
- hasUpstream: false,
- aheadCount: 0,
- behindCount: 0,
- pr: null,
- };
-
- const status = vi.fn(() => Effect.succeed(statusResult));
- const runStackedAction = vi.fn(() => Effect.void as any);
- const resolvePullRequest = vi.fn(() => Effect.void as any);
- const preparePullRequestThread = vi.fn(() => Effect.void as any);
- const gitManager: GitManagerShape = {
- status,
- resolvePullRequest,
- preparePullRequestThread,
- runStackedAction,
- };
-
- server = await createTestServer({ cwd: "/test", gitManager });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.gitStatus, {
- cwd: "/test",
- });
- expect(response.error).toBeUndefined();
- expect(response.result).toEqual(statusResult);
- expect(status).toHaveBeenCalledWith({ cwd: "/test" });
- });
-
- it("supports git pull request routing over websocket", async () => {
- const resolvePullRequestResult = {
- pullRequest: {
- number: 42,
- title: "PR thread flow",
- url: "https://github.com/pingdotgg/codething-mvp/pull/42",
- baseBranch: "main",
- headBranch: "feature/pr-threads",
- state: "open" as const,
- },
- };
- const preparePullRequestThreadResult = {
- ...resolvePullRequestResult,
- branch: "feature/pr-threads",
- worktreePath: "/tmp/pr-threads",
- };
-
- const gitManager: GitManagerShape = {
- status: vi.fn(() => Effect.void as any),
- resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)),
- preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)),
- runStackedAction: vi.fn(() => Effect.void as any),
- };
-
- server = await createTestServer({ cwd: "/test", gitManager });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, {
- cwd: "/test",
- reference: "#42",
- });
- expect(resolveResponse.error).toBeUndefined();
- expect(resolveResponse.result).toEqual(resolvePullRequestResult);
-
- const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, {
- cwd: "/test",
- reference: "42",
- mode: "worktree",
- });
- expect(prepareResponse.error).toBeUndefined();
- expect(prepareResponse.result).toEqual(preparePullRequestThreadResult);
- expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({
- cwd: "/test",
- reference: "#42",
- });
- expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({
- cwd: "/test",
- reference: "42",
- mode: "worktree",
- });
- });
-
- it("returns errors from git.runStackedAction", async () => {
- const runStackedAction = vi.fn(() =>
- Effect.fail(
- new GitManagerError({
- operation: "GitManager.test.runStackedAction",
- detail: "Cannot push from detached HEAD.",
- }),
- ),
- );
- const gitManager: GitManagerShape = {
- status: vi.fn(() => Effect.void as any),
- resolvePullRequest: vi.fn(() => Effect.void as any),
- preparePullRequestThread: vi.fn(() => Effect.void as any),
- runStackedAction,
- };
-
- server = await createTestServer({ cwd: "/test", gitManager });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- const [ws] = await connectAndAwaitWelcome(port);
- connections.push(ws);
-
- const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
- cwd: "/test",
- action: "commit_push",
- });
- expect(response.result).toBeUndefined();
- expect(response.error?.message).toContain("detached HEAD");
- expect(runStackedAction).toHaveBeenCalledWith({
- cwd: "/test",
- action: "commit_push",
- });
- });
-
- it("rejects websocket connections without a valid auth token", async () => {
- server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
- const addr = server.address();
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
-
- await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
-
- const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token");
- connections.push(authorizedWs);
- });
-});
diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts
index e22c23988b..ef02dad12b 100644
--- a/apps/server/src/wsServer.ts
+++ b/apps/server/src/wsServer.ts
@@ -9,100 +9,37 @@
import http from "node:http";
import type { Duplex } from "node:stream";
-import Mime from "@effect/platform-node/Mime";
import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
- type ClientOrchestrationCommand,
- type OrchestrationCommand,
ORCHESTRATION_WS_CHANNELS,
- ORCHESTRATION_WS_METHODS,
- PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
ProjectId,
ThreadId,
WS_CHANNELS,
- WS_METHODS,
WebSocketRequest,
type WsResponse as WsResponseMessage,
WsResponse,
type WsPushEnvelopeBase,
} from "@t3tools/contracts";
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
-import {
- Cause,
- Effect,
- Exit,
- FileSystem,
- Layer,
- Path,
- Ref,
- Result,
- Schema,
- Scope,
- ServiceMap,
- Stream,
- Struct,
-} from "effect";
+import { Cause, Effect, Exit, Layer, Path, Ref, Result, Schema, Scope, Stream } from "effect";
import { WebSocketServer, type WebSocket } from "ws";
import { createLogger } from "./logger";
-import { GitManager } from "./git/Services/GitManager.ts";
import { TerminalManager } from "./terminal/Services/Manager.ts";
import { Keybindings } from "./keybindings";
-import { searchWorkspaceEntries } from "./workspaceEntries";
import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
import { ProviderService } from "./provider/Services/ProviderService";
import { ProviderHealth } from "./provider/Services/ProviderHealth";
-import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
-import { clamp } from "effect/Number";
-import { Open, resolveAvailableEditors } from "./open";
+import { Open } from "./open";
import { ServerConfig } from "./config";
-import { GitCore } from "./git/Services/GitCore.ts";
-import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
-import {
- ATTACHMENTS_ROUTE_PREFIX,
- normalizeAttachmentRelativePath,
- resolveAttachmentRelativePath,
-} from "./attachmentPaths";
-
-import {
- createAttachmentId,
- resolveAttachmentPath,
- resolveAttachmentPathById,
-} from "./attachmentStore.ts";
-import { parseBase64DataUrl } from "./imageMime.ts";
import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
-import { expandHomePath } from "./os-jank.ts";
import { makeServerPushBus } from "./wsServer/pushBus.ts";
import { makeServerReadiness } from "./wsServer/readiness.ts";
import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
-/**
- * ServerShape - Service API for server lifecycle control.
- */
-export interface ServerShape {
- /**
- * Start HTTP and WebSocket listeners.
- */
- readonly start: Effect.Effect<
- http.Server,
- ServerLifecycleError,
- Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
- >;
-
- /**
- * Wait for process shutdown signals.
- */
- readonly stopSignal: Effect.Effect;
-}
-
-/**
- * Server - Service tag for HTTP/WebSocket lifecycle management.
- */
-export class Server extends ServiceMap.Service()("t3/wsServer/Server") {}
-
const isServerNotRunningError = (error: Error): boolean => {
const maybeCode = (error as NodeJS.ErrnoException).code;
return (
@@ -153,51 +90,11 @@ function websocketRawToString(raw: unknown): string | null {
return null;
}
-function toPosixRelativePath(input: string): string {
- return input.replaceAll("\\", "/");
-}
+const isWildcardHost = (host: string | undefined): boolean =>
+ host === "0.0.0.0" || host === "::" || host === "[::]";
-function resolveWorkspaceWritePath(params: {
- workspaceRoot: string;
- relativePath: string;
- path: Path.Path;
-}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> {
- const normalizedInputPath = params.relativePath.trim();
- if (params.path.isAbsolute(normalizedInputPath)) {
- return Effect.fail(
- new RouteRequestError({
- message: "Workspace file path must be relative to the project root.",
- }),
- );
- }
-
- const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath);
- const relativeToRoot = toPosixRelativePath(
- params.path.relative(params.workspaceRoot, absolutePath),
- );
- if (
- relativeToRoot.length === 0 ||
- relativeToRoot === "." ||
- relativeToRoot.startsWith("../") ||
- relativeToRoot === ".." ||
- params.path.isAbsolute(relativeToRoot)
- ) {
- return Effect.fail(
- new RouteRequestError({
- message: "Workspace file path must stay within the project root.",
- }),
- );
- }
-
- return Effect.succeed({
- absolutePath,
- relativePath: relativeToRoot,
- });
-}
-
-function stripRequestTag(body: T) {
- return Struct.omit(body, ["_tag"]);
-}
+const formatHostForUrl = (host: string): string =>
+ host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest);
@@ -205,15 +102,12 @@ const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest);
export type ServerCoreRuntimeServices =
| OrchestrationEngineService
| ProjectionSnapshotQuery
- | CheckpointDiffQuery
| OrchestrationReactor
| ProviderService
| ProviderHealth;
export type ServerRuntimeServices =
| ServerCoreRuntimeServices
- | GitManager
- | GitCore
| TerminalManager
| Keybindings
| Open
@@ -231,16 +125,40 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro
message: Schema.String,
}) {}
+const recordStartupHeartbeat = Effect.gen(function* () {
+ const analytics = yield* AnalyticsService;
+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
+ Effect.map((snapshot) => ({
+ threadCount: snapshot.threads.length,
+ projectCount: snapshot.projects.length,
+ })),
+ Effect.catch((cause) =>
+ Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
+ Effect.as({
+ threadCount: 0,
+ projectCount: 0,
+ }),
+ ),
+ ),
+ );
+
+ yield* analytics.record("server.boot.heartbeat", {
+ threadCount,
+ projectCount,
+ });
+});
+
export const createServer = Effect.fn(function* (): Effect.fn.Return<
http.Server,
ServerLifecycleError,
- Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
+ Scope.Scope | ServerRuntimeServices | ServerConfig | Path.Path
> {
const serverConfig = yield* ServerConfig;
const {
port,
cwd,
- keybindingsConfigPath,
staticDir,
devUrl,
authToken,
@@ -248,14 +166,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
logWebSocketEvents,
autoBootstrapProjectFromCwd,
} = serverConfig;
- const availableEditors = resolveAvailableEditors();
- const gitManager = yield* GitManager;
const terminalManager = yield* TerminalManager;
const keybindingsManager = yield* Keybindings;
const providerHealth = yield* ProviderHealth;
- const git = yield* GitCore;
- const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(
@@ -296,282 +210,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
);
yield* readiness.markKeybindingsReady;
- const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
- readonly command: ClientOrchestrationCommand;
- }) {
- const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) {
- const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim()));
- const workspaceStat = yield* fileSystem
- .stat(normalizedWorkspaceRoot)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!workspaceStat) {
- return yield* new RouteRequestError({
- message: `Project directory does not exist: ${normalizedWorkspaceRoot}`,
- });
- }
- if (workspaceStat.type !== "Directory") {
- return yield* new RouteRequestError({
- message: `Project path is not a directory: ${normalizedWorkspaceRoot}`,
- });
- }
- return normalizedWorkspaceRoot;
- });
-
- if (input.command.type === "project.create") {
- return {
- ...input.command,
- workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),
- } satisfies OrchestrationCommand;
- }
-
- if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {
- return {
- ...input.command,
- workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),
- } satisfies OrchestrationCommand;
- }
-
- if (input.command.type !== "thread.turn.start") {
- return input.command as OrchestrationCommand;
- }
- const turnStartCommand = input.command;
-
- const normalizedAttachments = yield* Effect.forEach(
- turnStartCommand.message.attachments,
- (attachment) =>
- Effect.gen(function* () {
- const parsed = parseBase64DataUrl(attachment.dataUrl);
- if (!parsed || !parsed.mimeType.startsWith("image/")) {
- return yield* new RouteRequestError({
- message: `Invalid image attachment payload for '${attachment.name}'.`,
- });
- }
-
- const bytes = Buffer.from(parsed.base64, "base64");
- if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
- return yield* new RouteRequestError({
- message: `Image attachment '${attachment.name}' is empty or too large.`,
- });
- }
-
- const attachmentId = createAttachmentId(turnStartCommand.threadId);
- if (!attachmentId) {
- return yield* new RouteRequestError({
- message: "Failed to create a safe attachment id.",
- });
- }
-
- const persistedAttachment = {
- type: "image" as const,
- id: attachmentId,
- name: attachment.name,
- mimeType: parsed.mimeType.toLowerCase(),
- sizeBytes: bytes.byteLength,
- };
-
- const attachmentPath = resolveAttachmentPath({
- attachmentsDir: serverConfig.attachmentsDir,
- attachment: persistedAttachment,
- });
- if (!attachmentPath) {
- return yield* new RouteRequestError({
- message: `Failed to resolve persisted path for '${attachment.name}'.`,
- });
- }
-
- yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
- Effect.mapError(
- () =>
- new RouteRequestError({
- message: `Failed to create attachment directory for '${attachment.name}'.`,
- }),
- ),
- );
- yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
- Effect.mapError(
- () =>
- new RouteRequestError({
- message: `Failed to persist attachment '${attachment.name}'.`,
- }),
- ),
- );
-
- return persistedAttachment;
- }),
- { concurrency: 1 },
- );
-
- return {
- ...turnStartCommand,
- message: {
- ...turnStartCommand.message,
- attachments: normalizedAttachments,
- },
- } satisfies OrchestrationCommand;
- });
-
- // HTTP server — serves static files or redirects to Vite dev server
- const httpServer = http.createServer((req, res) => {
- const respond = (
- statusCode: number,
- headers: Record,
- body?: string | Uint8Array,
- ) => {
- res.writeHead(statusCode, headers);
- res.end(body);
- };
-
- void Effect.runPromise(
- Effect.gen(function* () {
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
- if (tryHandleProjectFaviconRequest(url, res)) {
- return;
- }
-
- if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
- const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
- const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
- if (!normalizedRelativePath) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
- return;
- }
-
- const isIdLookup =
- !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
- const filePath = isIdLookup
- ? resolveAttachmentPathById({
- attachmentsDir: serverConfig.attachmentsDir,
- attachmentId: normalizedRelativePath,
- })
- : resolveAttachmentRelativePath({
- attachmentsDir: serverConfig.attachmentsDir,
- relativePath: normalizedRelativePath,
- });
- if (!filePath) {
- respond(
- isIdLookup ? 404 : 400,
- { "Content-Type": "text/plain" },
- isIdLookup ? "Not Found" : "Invalid attachment path",
- );
- return;
- }
-
- const fileInfo = yield* fileSystem
- .stat(filePath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!fileInfo || fileInfo.type !== "File") {
- respond(404, { "Content-Type": "text/plain" }, "Not Found");
- return;
- }
-
- const contentType = Mime.getType(filePath) ?? "application/octet-stream";
- res.writeHead(200, {
- "Content-Type": contentType,
- "Cache-Control": "public, max-age=31536000, immutable",
- });
- const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) =>
- Effect.sync(() => {
- if (!res.destroyed) {
- res.write(chunk);
- }
- }),
- ).pipe(Effect.exit);
- if (Exit.isFailure(streamExit)) {
- if (!res.destroyed) {
- res.destroy();
- }
- return;
- }
- if (!res.writableEnded) {
- res.end();
- }
- return;
- }
-
- // In dev mode, redirect to Vite dev server
- if (devUrl) {
- respond(302, { Location: devUrl.href });
- return;
- }
-
- // Serve static files from the web app build
- if (!staticDir) {
- respond(
- 503,
- { "Content-Type": "text/plain" },
- "No static directory configured and no dev URL set.",
- );
- return;
- }
-
- const staticRoot = path.resolve(staticDir);
- const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname;
- const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
- const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
- const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
- const hasPathTraversalSegment = staticRelativePath.startsWith("..");
- if (
- staticRelativePath.length === 0 ||
- hasRawLeadingParentSegment ||
- hasPathTraversalSegment ||
- staticRelativePath.includes("\0")
- ) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
- return;
- }
-
- const isWithinStaticRoot = (candidate: string) =>
- candidate === staticRoot ||
- candidate.startsWith(
- staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`,
- );
-
- let filePath = path.resolve(staticRoot, staticRelativePath);
- if (!isWithinStaticRoot(filePath)) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
- return;
- }
-
- const ext = path.extname(filePath);
- if (!ext) {
- filePath = path.resolve(filePath, "index.html");
- if (!isWithinStaticRoot(filePath)) {
- respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
- return;
- }
- }
-
- const fileInfo = yield* fileSystem
- .stat(filePath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!fileInfo || fileInfo.type !== "File") {
- const indexPath = path.resolve(staticRoot, "index.html");
- const indexData = yield* fileSystem
- .readFile(indexPath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!indexData) {
- respond(404, { "Content-Type": "text/plain" }, "Not Found");
- return;
- }
- respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData);
- return;
- }
-
- const contentType = Mime.getType(filePath) ?? "application/octet-stream";
- const data = yield* fileSystem
- .readFile(filePath)
- .pipe(Effect.catch(() => Effect.succeed(null)));
- if (!data) {
- respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
- return;
- }
- respond(200, { "Content-Type": contentType }, data);
- }),
- ).catch(() => {
- if (!res.headersSent) {
- respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
- }
- });
+ // HTTP behavior migrated to `httpRouter.ts` + `server.ts`.
+ // wsServer remains focused on WebSocket lifecycle during migration.
+ const httpServer = http.createServer((_req, res) => {
+ res.writeHead(404, { "Content-Type": "text/plain" });
+ res.end("HTTP routes moved to HttpRouter runtime.");
});
// WebSocket server — upgrades from the HTTP server
@@ -600,9 +243,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
const orchestrationEngine = yield* OrchestrationEngineService;
const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
- const checkpointDiffQuery = yield* CheckpointDiffQuery;
const orchestrationReactor = yield* OrchestrationReactor;
- const { openInEditor } = yield* Open;
+ const { openBrowser } = yield* Open;
const subscriptionsScope = yield* Scope.make("sequential");
yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void));
@@ -685,7 +327,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
}
const runtimeServices = yield* Effect.services<
- ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
+ ServerRuntimeServices | ServerConfig | Path.Path
>();
const runPromise = Effect.runPromiseWith(runtimeServices);
@@ -700,196 +342,43 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
);
yield* readiness.markHttpListening;
- yield* Effect.addFinalizer(() =>
- Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),
- );
-
- const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {
- switch (request.body._tag) {
- case ORCHESTRATION_WS_METHODS.getSnapshot:
- return yield* projectionReadModelQuery.getSnapshot();
-
- case ORCHESTRATION_WS_METHODS.dispatchCommand: {
- const { command } = request.body;
- const normalizedCommand = yield* normalizeDispatchCommand({ command });
- return yield* orchestrationEngine.dispatch(normalizedCommand);
- }
-
- case ORCHESTRATION_WS_METHODS.getTurnDiff: {
- const body = stripRequestTag(request.body);
- return yield* checkpointDiffQuery.getTurnDiff(body);
- }
-
- case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
- const body = stripRequestTag(request.body);
- return yield* checkpointDiffQuery.getFullThreadDiff(body);
- }
-
- case ORCHESTRATION_WS_METHODS.replayEvents: {
- const { fromSequenceExclusive } = request.body;
- return yield* Stream.runCollect(
- orchestrationEngine.readEvents(
- clamp(fromSequenceExclusive, {
- maximum: Number.MAX_SAFE_INTEGER,
- minimum: 0,
- }),
- ),
- ).pipe(Effect.map((events) => Array.from(events)));
- }
-
- case WS_METHODS.projectsSearchEntries: {
- const body = stripRequestTag(request.body);
- return yield* Effect.tryPromise({
- try: () => searchWorkspaceEntries(body),
- catch: (cause) =>
- new RouteRequestError({
- message: `Failed to search workspace entries: ${String(cause)}`,
- }),
- });
- }
-
- case WS_METHODS.projectsWriteFile: {
- const body = stripRequestTag(request.body);
- const target = yield* resolveWorkspaceWritePath({
- workspaceRoot: body.cwd,
- relativePath: body.relativePath,
- path,
- });
- yield* fileSystem
- .makeDirectory(path.dirname(target.absolutePath), { recursive: true })
- .pipe(
- Effect.mapError(
- (cause) =>
- new RouteRequestError({
- message: `Failed to prepare workspace path: ${String(cause)}`,
- }),
- ),
- );
- yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe(
- Effect.mapError(
- (cause) =>
- new RouteRequestError({
- message: `Failed to write workspace file: ${String(cause)}`,
- }),
- ),
- );
- return { relativePath: target.relativePath };
- }
-
- case WS_METHODS.shellOpenInEditor: {
- const body = stripRequestTag(request.body);
- return yield* openInEditor(body);
- }
-
- case WS_METHODS.gitStatus: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.status(body);
- }
-
- case WS_METHODS.gitPull: {
- const body = stripRequestTag(request.body);
- return yield* git.pullCurrentBranch(body.cwd);
- }
-
- case WS_METHODS.gitRunStackedAction: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.runStackedAction(body);
- }
-
- case WS_METHODS.gitResolvePullRequest: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.resolvePullRequest(body);
- }
-
- case WS_METHODS.gitPreparePullRequestThread: {
- const body = stripRequestTag(request.body);
- return yield* gitManager.preparePullRequestThread(body);
- }
-
- case WS_METHODS.gitListBranches: {
- const body = stripRequestTag(request.body);
- return yield* git.listBranches(body);
- }
-
- case WS_METHODS.gitCreateWorktree: {
- const body = stripRequestTag(request.body);
- return yield* git.createWorktree(body);
- }
-
- case WS_METHODS.gitRemoveWorktree: {
- const body = stripRequestTag(request.body);
- return yield* git.removeWorktree(body);
- }
-
- case WS_METHODS.gitCreateBranch: {
- const body = stripRequestTag(request.body);
- return yield* git.createBranch(body);
- }
-
- case WS_METHODS.gitCheckout: {
- const body = stripRequestTag(request.body);
- return yield* Effect.scoped(git.checkoutBranch(body));
- }
-
- case WS_METHODS.gitInit: {
- const body = stripRequestTag(request.body);
- return yield* git.initRepo(body);
- }
-
- case WS_METHODS.terminalOpen: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.open(body);
- }
-
- case WS_METHODS.terminalWrite: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.write(body);
- }
-
- case WS_METHODS.terminalResize: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.resize(body);
- }
+ if (!devUrl && !staticDir) {
+ yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {
+ hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
+ });
+ }
- case WS_METHODS.terminalClear: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.clear(body);
- }
+ const localUrl = `http://localhost:${port}`;
+ const bindUrl =
+ host && !isWildcardHost(host) ? `http://${formatHostForUrl(host)}:${port}` : localUrl;
+ const { authToken: _authToken, devUrl: configDevUrl, ...safeConfig } = serverConfig;
+ yield* Effect.logInfo("T3 Code running", {
+ ...safeConfig,
+ devUrl: configDevUrl?.toString(),
+ authEnabled: Boolean(authToken),
+ });
- case WS_METHODS.terminalRestart: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.restart(body);
- }
+ if (!serverConfig.noBrowser) {
+ const target = configDevUrl?.toString() ?? bindUrl;
+ yield* openBrowser(target).pipe(
+ Effect.catch(() =>
+ Effect.logInfo("browser auto-open unavailable", {
+ hint: `Open ${target} in your browser.`,
+ }),
+ ),
+ );
+ }
- case WS_METHODS.terminalClose: {
- const body = stripRequestTag(request.body);
- return yield* terminalManager.close(body);
- }
+ yield* recordStartupHeartbeat;
- case WS_METHODS.serverGetConfig:
- const keybindingsConfig = yield* keybindingsManager.loadConfigState;
- return {
- cwd,
- keybindingsConfigPath,
- keybindings: keybindingsConfig.keybindings,
- issues: keybindingsConfig.issues,
- providers: providerStatuses,
- availableEditors,
- };
-
- case WS_METHODS.serverUpsertKeybinding: {
- const body = stripRequestTag(request.body);
- const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body);
- return { keybindings: keybindingsConfig, issues: [] };
- }
+ yield* Effect.addFinalizer(() =>
+ Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),
+ );
- default: {
- const _exhaustiveCheck: never = request.body;
- return yield* new RouteRequestError({
- message: `Unknown method: ${String(_exhaustiveCheck)}`,
- });
- }
- }
+ const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {
+ return yield* new RouteRequestError({
+ message: `WebSocket method '${request.body._tag}' is now handled by RpcServer at /ws`,
+ });
});
const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) {
@@ -1000,7 +489,4 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return httpServer;
});
-export const ServerLive = Layer.succeed(Server, {
- start: createServer(),
- stopSignal: Effect.never,
-} satisfies ServerShape);
+export const ServerLayer = Layer.effectDiscard(createServer());
diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts
index f89bc7d3d7..f11dd37869 100644
--- a/apps/server/tsdown.config.ts
+++ b/apps/server/tsdown.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from "tsdown";
export default defineConfig({
- entry: ["src/index.ts"],
+ entry: ["src/bin.ts"],
format: ["esm", "cjs"],
checks: {
legacyCjs: false,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 34f9c4b82f..387884e804 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -142,7 +142,9 @@ function EventRouter() {
const pathnameRef = useRef(pathname);
const handledBootstrapThreadIdRef = useRef(null);
- pathnameRef.current = pathname;
+ useEffect(() => {
+ pathnameRef.current = pathname;
+ }, [pathname]);
useEffect(() => {
const api = readNativeApi();
@@ -259,9 +261,9 @@ function EventRouter() {
// during subscribe. Skip the toast for that replay so effect re-runs
// don't produce duplicate toasts.
let subscribed = false;
- const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
+ const unsubServerConfigUpdated = onServerConfigUpdated((payload, source) => {
void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
- if (!subscribed) return;
+ if (!subscribed || source !== "keybindingsUpdated") return;
const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
if (!issue) {
toastManager.add({
diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts
index 2323380da0..eb769f84ea 100644
--- a/apps/web/src/wsNativeApi.test.ts
+++ b/apps/web/src/wsNativeApi.test.ts
@@ -2,18 +2,15 @@ import {
CommandId,
type ContextMenuItem,
EventId,
- ORCHESTRATION_WS_CHANNELS,
ORCHESTRATION_WS_METHODS,
- type OrchestrationEvent,
ProjectId,
+ type OrchestrationEvent,
+ type ServerConfig,
+ type ServerConfigStreamEvent,
+ type ServerLifecycleStreamEvent,
+ type ServerProviderStatus,
ThreadId,
- type WsPushChannel,
- type WsPushData,
- type WsPushMessage,
- WS_CHANNELS,
WS_METHODS,
- type WsPush,
- type ServerProviderStatus,
} from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -25,26 +22,17 @@ const showContextMenuFallbackMock =
position?: { x: number; y: number },
) => Promise
>();
-const channelListeners = new Map void>>();
-const latestPushByChannel = new Map();
+const streamListeners = new Map void>>();
const subscribeMock = vi.fn<
- (
- channel: string,
- listener: (message: WsPush) => void,
- options?: { replayLatest?: boolean },
- ) => () => void
->((channel, listener, options) => {
- const listeners = channelListeners.get(channel) ?? new Set<(message: WsPush) => void>();
+ (method: string, params: unknown, listener: (event: unknown) => void) => () => void
+>((method, _params, listener) => {
+ const listeners = streamListeners.get(method) ?? new Set<(event: unknown) => void>();
listeners.add(listener);
- channelListeners.set(channel, listeners);
- const latest = latestPushByChannel.get(channel);
- if (latest && options?.replayLatest) {
- listener(latest);
- }
+ streamListeners.set(method, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
- channelListeners.delete(channel);
+ streamListeners.delete(method);
}
};
});
@@ -54,9 +42,7 @@ vi.mock("./wsTransport", () => {
WsTransport: class MockWsTransport {
request = requestMock;
subscribe = subscribeMock;
- getLatestPush(channel: string) {
- return latestPushByChannel.get(channel) ?? null;
- }
+ dispose() {}
},
};
});
@@ -65,23 +51,24 @@ vi.mock("./contextMenuFallback", () => ({
showContextMenuFallback: showContextMenuFallbackMock,
}));
-let nextPushSequence = 1;
-
-function emitPush(channel: C, data: WsPushData): void {
- const listeners = channelListeners.get(channel);
- const message = {
- type: "push" as const,
- sequence: nextPushSequence++,
- channel,
- data,
- } as WsPushMessage;
- latestPushByChannel.set(channel, message);
- if (!listeners) return;
+function emitStreamEvent(method: string, event: unknown) {
+ const listeners = streamListeners.get(method);
+ if (!listeners) {
+ return;
+ }
for (const listener of listeners) {
- listener(message);
+ listener(event);
}
}
+function emitLifecycleEvent(event: ServerLifecycleStreamEvent) {
+ emitStreamEvent(WS_METHODS.subscribeServerLifecycle, event);
+}
+
+function emitServerConfigEvent(event: ServerConfigStreamEvent) {
+ emitStreamEvent(WS_METHODS.subscribeServerConfig, event);
+}
+
function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } {
const testGlobal = globalThis as typeof globalThis & {
window?: Window & typeof globalThis & { desktopBridge?: unknown };
@@ -102,14 +89,21 @@ const defaultProviders: ReadonlyArray = [
},
];
+const baseServerConfig: ServerConfig = {
+ cwd: "/tmp/workspace",
+ keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
+ keybindings: [],
+ issues: [],
+ providers: defaultProviders,
+ availableEditors: ["cursor"],
+};
+
beforeEach(() => {
vi.resetModules();
requestMock.mockReset();
showContextMenuFallbackMock.mockReset();
subscribeMock.mockClear();
- channelListeners.clear();
- latestPushByChannel.clear();
- nextPushSequence = 1;
+ streamListeners.clear();
Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
});
@@ -118,38 +112,53 @@ afterEach(() => {
});
describe("wsNativeApi", () => {
- it("delivers and caches valid server.welcome payloads", async () => {
+ it("delivers and caches welcome lifecycle events", async () => {
const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
createWsNativeApi();
const listener = vi.fn();
onServerWelcome(listener);
- const payload = { cwd: "/tmp/workspace", projectName: "t3-code" };
- emitPush(WS_CHANNELS.serverWelcome, payload);
+ emitLifecycleEvent({
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: { cwd: "/tmp/workspace", projectName: "t3-code" },
+ });
expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
+ expect(listener).toHaveBeenCalledWith({
+ cwd: "/tmp/workspace",
+ projectName: "t3-code",
+ });
const lateListener = vi.fn();
onServerWelcome(lateListener);
expect(lateListener).toHaveBeenCalledTimes(1);
- expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload));
+ expect(lateListener).toHaveBeenCalledWith({
+ cwd: "/tmp/workspace",
+ projectName: "t3-code",
+ });
});
- it("preserves bootstrap ids from server.welcome payloads", async () => {
+ it("preserves bootstrap ids from welcome lifecycle events", async () => {
const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
createWsNativeApi();
const listener = vi.fn();
onServerWelcome(listener);
- emitPush(WS_CHANNELS.serverWelcome, {
- cwd: "/tmp/workspace",
- projectName: "t3-code",
- bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
- bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
+ emitLifecycleEvent({
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: {
+ cwd: "/tmp/workspace",
+ projectName: "t3-code",
+ bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
+ bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
+ },
});
expect(listener).toHaveBeenCalledTimes(1);
@@ -163,77 +172,112 @@ describe("wsNativeApi", () => {
);
});
- it("delivers successive server.welcome payloads to active listeners", async () => {
- const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
-
- createWsNativeApi();
- const listener = vi.fn();
- onServerWelcome(listener);
-
- emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/one", projectName: "one" });
- emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" });
-
- expect(listener).toHaveBeenCalledTimes(2);
- expect(listener).toHaveBeenLastCalledWith(
- expect.objectContaining({
- cwd: "/tmp/workspace",
- projectName: "t3-code",
- }),
- );
- });
-
- it("delivers and caches valid server.configUpdated payloads", async () => {
+ it("delivers and caches current server config from the config stream snapshot", async () => {
const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
- createWsNativeApi();
+ const api = createWsNativeApi();
const listener = vi.fn();
onServerConfigUpdated(listener);
- const payload = {
- issues: [
- {
- kind: "keybindings.invalid-entry",
- index: 1,
- message: "Entry at index 1 is invalid.",
- },
- ],
- providers: defaultProviders,
- } as const;
- emitPush(WS_CHANNELS.serverConfigUpdated, payload);
+ const pendingConfig = api.server.getConfig();
+ emitServerConfigEvent({
+ version: 1,
+ type: "snapshot",
+ config: baseServerConfig,
+ });
+ await expect(pendingConfig).resolves.toEqual(baseServerConfig);
expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenCalledWith(payload);
+ expect(listener).toHaveBeenCalledWith(
+ {
+ issues: [],
+ providers: defaultProviders,
+ },
+ "snapshot",
+ );
const lateListener = vi.fn();
onServerConfigUpdated(lateListener);
+
expect(lateListener).toHaveBeenCalledTimes(1);
- expect(lateListener).toHaveBeenCalledWith(payload);
+ expect(lateListener).toHaveBeenCalledWith(
+ {
+ issues: [],
+ providers: defaultProviders,
+ },
+ "snapshot",
+ );
});
- it("delivers successive server.configUpdated payloads to active listeners", async () => {
+ it("merges config stream updates into the cached server config", async () => {
const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
- createWsNativeApi();
+ const api = createWsNativeApi();
const listener = vi.fn();
onServerConfigUpdated(listener);
- emitPush(WS_CHANNELS.serverConfigUpdated, {
- issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
- providers: defaultProviders,
+ emitServerConfigEvent({
+ version: 1,
+ type: "snapshot",
+ config: baseServerConfig,
});
- emitPush(WS_CHANNELS.serverConfigUpdated, {
- issues: [],
- providers: defaultProviders,
+ emitServerConfigEvent({
+ version: 1,
+ type: "keybindingsUpdated",
+ payload: {
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ },
});
- expect(listener).toHaveBeenCalledTimes(2);
- expect(listener).toHaveBeenLastCalledWith({
- issues: [],
- providers: defaultProviders,
+ const nextProviders: ReadonlyArray = [
+ {
+ provider: "codex",
+ status: "warning",
+ available: true,
+ authStatus: "authenticated",
+ checkedAt: "2026-01-02T00:00:00.000Z",
+ message: "rate limited",
+ },
+ ];
+ emitServerConfigEvent({
+ version: 1,
+ type: "providerStatuses",
+ payload: {
+ providers: nextProviders,
+ },
});
+
+ await expect(api.server.getConfig()).resolves.toEqual({
+ ...baseServerConfig,
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ providers: nextProviders,
+ });
+ expect(listener).toHaveBeenNthCalledWith(
+ 1,
+ {
+ issues: [],
+ providers: defaultProviders,
+ },
+ "snapshot",
+ );
+ expect(listener).toHaveBeenNthCalledWith(
+ 2,
+ {
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ providers: defaultProviders,
+ },
+ "keybindingsUpdated",
+ );
+ expect(listener).toHaveBeenLastCalledWith(
+ {
+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
+ providers: nextProviders,
+ },
+ "providerStatuses",
+ );
});
- it("forwards valid terminal and orchestration events", async () => {
+ it("forwards terminal and orchestration stream events", async () => {
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
@@ -250,7 +294,7 @@ describe("wsNativeApi", () => {
type: "output",
data: "hello",
} as const;
- emitPush(WS_CHANNELS.terminalEvent, terminalEvent);
+ emitStreamEvent(WS_METHODS.subscribeTerminalEvents, terminalEvent);
const orchestrationEvent = {
sequence: 1,
@@ -273,7 +317,7 @@ describe("wsNativeApi", () => {
updatedAt: "2026-02-24T00:00:00.000Z",
},
} satisfies Extract;
- emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent);
+ emitStreamEvent(WS_METHODS.subscribeOrchestrationDomainEvents, orchestrationEvent);
expect(onTerminalEvent).toHaveBeenCalledTimes(1);
expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
@@ -281,8 +325,8 @@ describe("wsNativeApi", () => {
expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
});
- it("wraps orchestration dispatch commands in the command envelope", async () => {
- requestMock.mockResolvedValue(undefined);
+ it("sends orchestration dispatch commands as the direct RPC payload", async () => {
+ requestMock.mockResolvedValue({ sequence: 1 });
const { createWsNativeApi } = await import("./wsNativeApi");
const api = createWsNativeApi();
@@ -297,12 +341,10 @@ describe("wsNativeApi", () => {
} as const;
await api.orchestration.dispatchCommand(command);
- expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, {
- command,
- });
+ expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, command);
});
- it("forwards workspace file writes to the websocket project method", async () => {
+ it("forwards workspace file writes to the project RPC", async () => {
requestMock.mockResolvedValue({ relativePath: "plan.md" });
const { createWsNativeApi } = await import("./wsNativeApi");
@@ -320,7 +362,7 @@ describe("wsNativeApi", () => {
});
});
- it("forwards full-thread diff requests to the orchestration websocket method", async () => {
+ it("forwards full-thread diff requests to the orchestration RPC", async () => {
requestMock.mockResolvedValue({ diff: "patch" });
const { createWsNativeApi } = await import("./wsNativeApi");
@@ -336,7 +378,22 @@ describe("wsNativeApi", () => {
});
});
- it("forwards context menu metadata to desktop bridge", async () => {
+ it("uses the config snapshot promise for server.getConfig consumers", async () => {
+ const { createWsNativeApi } = await import("./wsNativeApi");
+
+ const api = createWsNativeApi();
+ const configPromise = api.server.getConfig();
+
+ emitServerConfigEvent({
+ version: 1,
+ type: "snapshot",
+ config: baseServerConfig,
+ });
+
+ await expect(configPromise).resolves.toEqual(baseServerConfig);
+ });
+
+ it("forwards context menu metadata to the desktop bridge", async () => {
const showContextMenu = vi.fn().mockResolvedValue("delete");
Object.defineProperty(getWindowForTest(), "desktopBridge", {
configurable: true,
@@ -365,7 +422,7 @@ describe("wsNativeApi", () => {
);
});
- it("uses fallback context menu when desktop bridge is unavailable", async () => {
+ it("uses the fallback context menu when the desktop bridge is unavailable", async () => {
showContextMenuFallbackMock.mockResolvedValue("delete");
Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts
index ddfffbde69..021a4d4b44 100644
--- a/apps/web/src/wsNativeApi.ts
+++ b/apps/web/src/wsNativeApi.ts
@@ -1,10 +1,11 @@
import {
- ORCHESTRATION_WS_CHANNELS,
ORCHESTRATION_WS_METHODS,
type ContextMenuItem,
type NativeApi,
- ServerConfigUpdatedPayload,
- WS_CHANNELS,
+ type ServerConfig,
+ type ServerConfigStreamEvent,
+ type ServerConfigUpdatedPayload,
+ type ServerLifecycleStreamEvent,
WS_METHODS,
type WsWelcomePayload,
} from "@t3tools/contracts";
@@ -14,22 +15,128 @@ import { WsTransport } from "./wsTransport";
let instance: { api: NativeApi; transport: WsTransport } | null = null;
const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
-const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
+export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];
+
+interface ServerConfigUpdatedNotification {
+ readonly payload: ServerConfigUpdatedPayload;
+ readonly source: ServerConfigUpdateSource;
+}
+
+const serverConfigUpdatedListeners = new Set<
+ (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void
+>();
+const pendingServerConfigResolvers = new Set<(config: ServerConfig) => void>();
+
+let latestWelcomePayload: WsWelcomePayload | null = null;
+let latestServerConfig: ServerConfig | null = null;
+let latestServerConfigUpdated: ServerConfigUpdatedNotification | null = null;
+
+function emitWelcome(payload: WsWelcomePayload) {
+ latestWelcomePayload = payload;
+ for (const listener of welcomeListeners) {
+ try {
+ listener(payload);
+ } catch {
+ // Swallow listener errors.
+ }
+ }
+}
+
+function resolveServerConfig(config: ServerConfig) {
+ latestServerConfig = config;
+ for (const resolve of pendingServerConfigResolvers) {
+ resolve(config);
+ }
+ pendingServerConfigResolvers.clear();
+}
+
+function emitServerConfigUpdated(
+ payload: ServerConfigUpdatedPayload,
+ source: ServerConfigUpdateSource,
+) {
+ latestServerConfigUpdated = { payload, source };
+ for (const listener of serverConfigUpdatedListeners) {
+ try {
+ listener(payload, source);
+ } catch {
+ // Swallow listener errors.
+ }
+ }
+}
+
+function applyServerConfigEvent(event: ServerConfigStreamEvent) {
+ switch (event.type) {
+ case "snapshot": {
+ resolveServerConfig(event.config);
+ emitServerConfigUpdated(
+ {
+ issues: event.config.issues,
+ providers: event.config.providers,
+ },
+ event.type,
+ );
+ return;
+ }
+ case "keybindingsUpdated": {
+ if (!latestServerConfig) {
+ return;
+ }
+ const nextConfig = {
+ ...latestServerConfig,
+ issues: event.payload.issues,
+ } satisfies ServerConfig;
+ resolveServerConfig(nextConfig);
+ emitServerConfigUpdated(
+ {
+ issues: nextConfig.issues,
+ providers: nextConfig.providers,
+ },
+ event.type,
+ );
+ return;
+ }
+ case "providerStatuses": {
+ if (!latestServerConfig) {
+ return;
+ }
+ const nextConfig = {
+ ...latestServerConfig,
+ providers: event.payload.providers,
+ } satisfies ServerConfig;
+ resolveServerConfig(nextConfig);
+ emitServerConfigUpdated(
+ {
+ issues: nextConfig.issues,
+ providers: nextConfig.providers,
+ },
+ event.type,
+ );
+ return;
+ }
+ }
+}
+
+function getServerConfigSnapshot(): Promise {
+ if (latestServerConfig) {
+ return Promise.resolve(latestServerConfig);
+ }
+ return new Promise((resolve) => {
+ pendingServerConfigResolvers.add(resolve);
+ });
+}
/**
* Subscribe to the server welcome message. If a welcome was already received
* before this call, the listener fires synchronously with the cached payload.
- * This avoids the race between WebSocket connect and React effect registration.
*/
export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {
welcomeListeners.add(listener);
- const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null;
- if (latestWelcome) {
+ if (latestWelcomePayload) {
try {
- listener(latestWelcome);
+ listener(latestWelcomePayload);
} catch {
- // Swallow listener errors
+ // Swallow listener errors.
}
}
@@ -43,17 +150,15 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void):
* late subscribers to avoid missing config validation feedback.
*/
export function onServerConfigUpdated(
- listener: (payload: ServerConfigUpdatedPayload) => void,
+ listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void,
): () => void {
serverConfigUpdatedListeners.add(listener);
- const latestConfig =
- instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null;
- if (latestConfig) {
+ if (latestServerConfigUpdated) {
try {
- listener(latestConfig);
+ listener(latestServerConfigUpdated.payload, latestServerConfigUpdated.source);
} catch {
- // Swallow listener errors
+ // Swallow listener errors.
}
}
@@ -63,29 +168,23 @@ export function onServerConfigUpdated(
}
export function createWsNativeApi(): NativeApi {
- if (instance) return instance.api;
+ if (instance) {
+ return instance.api;
+ }
const transport = new WsTransport();
- transport.subscribe(WS_CHANNELS.serverWelcome, (message) => {
- const payload = message.data;
- for (const listener of welcomeListeners) {
- try {
- listener(payload);
- } catch {
- // Swallow listener errors
+ transport.subscribe(
+ WS_METHODS.subscribeServerLifecycle,
+ {},
+ (event: ServerLifecycleStreamEvent) => {
+ if (event.type === "welcome") {
+ emitWelcome(event.payload);
}
- }
- });
- transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => {
- const payload = message.data;
- for (const listener of serverConfigUpdatedListeners) {
- try {
- listener(payload);
- } catch {
- // Swallow listener errors
- }
- }
+ },
+ );
+ transport.subscribe(WS_METHODS.subscribeServerConfig, {}, (event: ServerConfigStreamEvent) => {
+ applyServerConfigEvent(event);
});
const api: NativeApi = {
@@ -108,8 +207,7 @@ export function createWsNativeApi(): NativeApi {
clear: (input) => transport.request(WS_METHODS.terminalClear, input),
restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
close: (input) => transport.request(WS_METHODS.terminalClose, input),
- onEvent: (callback) =>
- transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)),
+ onEvent: (callback) => transport.subscribe(WS_METHODS.subscribeTerminalEvents, {}, callback),
},
projects: {
searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
@@ -127,8 +225,6 @@ export function createWsNativeApi(): NativeApi {
return;
}
- // Some mobile browsers can return null here even when the tab opens.
- // Avoid false negatives and let the browser handle popup policy.
window.open(url, "_blank", "noopener,noreferrer");
},
},
@@ -158,22 +254,20 @@ export function createWsNativeApi(): NativeApi {
},
},
server: {
- getConfig: () => transport.request(WS_METHODS.serverGetConfig),
+ getConfig: () => getServerConfigSnapshot(),
upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
},
orchestration: {
- getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
+ getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot, {}),
dispatchCommand: (command) =>
- transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }),
+ transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command),
getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),
getFullThreadDiff: (input) =>
transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input),
replayEvents: (fromSequenceExclusive) =>
transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
onDomainEvent: (callback) =>
- transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) =>
- callback(message.data),
- ),
+ transport.subscribe(WS_METHODS.subscribeOrchestrationDomainEvents, {}, callback),
},
};
diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts
index d905bbcf9a..4b615d64ce 100644
--- a/apps/web/src/wsTransport.test.ts
+++ b/apps/web/src/wsTransport.test.ts
@@ -1,10 +1,11 @@
-import { WS_CHANNELS } from "@t3tools/contracts";
+import { WS_METHODS } from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WsTransport } from "./wsTransport";
type WsEventType = "open" | "message" | "close" | "error";
-type WsListener = (event?: { data?: unknown }) => void;
+type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
+type WsListener = (event?: WsEvent) => void;
const sockets: MockWebSocket[] = [];
@@ -16,9 +17,11 @@ class MockWebSocket {
readyState = MockWebSocket.CONNECTING;
readonly sent: string[] = [];
+ readonly url: string;
private readonly listeners = new Map>();
- constructor(_url: string) {
+ constructor(url: string) {
+ this.url = url;
sockets.push(this);
}
@@ -28,25 +31,29 @@ class MockWebSocket {
this.listeners.set(type, listeners);
}
+ removeEventListener(type: WsEventType, listener: WsListener) {
+ this.listeners.get(type)?.delete(listener);
+ }
+
send(data: string) {
this.sent.push(data);
}
- close() {
+ close(code = 1000, reason = "") {
this.readyState = MockWebSocket.CLOSED;
- this.emit("close");
+ this.emit("close", { code, reason, type: "close" });
}
open() {
this.readyState = MockWebSocket.OPEN;
- this.emit("open");
+ this.emit("open", { type: "open" });
}
serverMessage(data: unknown) {
- this.emit("message", { data });
+ this.emit("message", { data, type: "message" });
}
- private emit(type: WsEventType, event?: { data?: unknown }) {
+ private emit(type: WsEventType, event?: WsEvent) {
const listeners = this.listeners.get(type);
if (!listeners) return;
for (const listener of listeners) {
@@ -65,13 +72,28 @@ function getSocket(): MockWebSocket {
return socket;
}
+async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise {
+ const startedAt = Date.now();
+ for (;;) {
+ try {
+ assertion();
+ return;
+ } catch (error) {
+ if (Date.now() - startedAt >= timeoutMs) {
+ throw error;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ }
+}
+
beforeEach(() => {
sockets.length = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
- location: { hostname: "localhost", port: "3020" },
+ location: { hostname: "localhost", port: "3020", protocol: "ws:" },
desktopBridge: undefined,
},
});
@@ -85,125 +107,205 @@ afterEach(() => {
});
describe("WsTransport", () => {
- it("routes valid push envelopes to channel listeners", () => {
+ it("normalizes root websocket urls to /ws and preserves query params", async () => {
+ const transport = new WsTransport("ws://localhost:3020/?token=secret-token");
+
+ await waitFor(() => {
+ expect(sockets).toHaveLength(1);
+ });
+
+ expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token");
+ transport.dispose();
+ });
+
+ it("sends unary RPC requests and resolves successful exits", async () => {
const transport = new WsTransport("ws://localhost:3020");
+
+ const requestPromise = transport.request(WS_METHODS.serverUpsertKeybinding, {
+ command: "terminal.toggle",
+ key: "ctrl+k",
+ });
+
+ await waitFor(() => {
+ expect(sockets).toHaveLength(1);
+ });
+
const socket = getSocket();
+ expect(socket.sent).toHaveLength(0);
socket.open();
- const listener = vi.fn();
- transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
+ await waitFor(() => {
+ expect(socket.sent).toHaveLength(1);
+ });
+
+ const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as {
+ _tag: string;
+ id: string;
+ payload: unknown;
+ tag: string;
+ };
+ expect(requestMessage).toMatchObject({
+ _tag: "Request",
+ tag: WS_METHODS.serverUpsertKeybinding,
+ payload: {
+ command: "terminal.toggle",
+ key: "ctrl+k",
+ },
+ });
socket.serverMessage(
JSON.stringify({
- type: "push",
- sequence: 1,
- channel: WS_CHANNELS.serverConfigUpdated,
- data: { issues: [], providers: [] },
+ _tag: "Exit",
+ requestId: requestMessage.id,
+ exit: {
+ _tag: "Success",
+ value: {
+ keybindings: [],
+ issues: [],
+ },
+ },
}),
);
- expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenCalledWith({
- type: "push",
- sequence: 1,
- channel: WS_CHANNELS.serverConfigUpdated,
- data: { issues: [], providers: [] },
+ await expect(requestPromise).resolves.toEqual({
+ keybindings: [],
+ issues: [],
});
transport.dispose();
});
- it("resolves pending requests for valid response envelopes", async () => {
+ it("delivers stream chunks to subscribers", async () => {
const transport = new WsTransport("ws://localhost:3020");
+ const listener = vi.fn();
+
+ const unsubscribe = transport.subscribe(WS_METHODS.subscribeServerLifecycle, {}, listener);
+ await waitFor(() => {
+ expect(sockets).toHaveLength(1);
+ });
+
const socket = getSocket();
socket.open();
- const requestPromise = transport.request("projects.list");
- const sent = socket.sent.at(-1);
- if (!sent) {
- throw new Error("Expected request envelope to be sent");
- }
+ await waitFor(() => {
+ expect(socket.sent).toHaveLength(1);
+ });
+
+ const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
+ expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle);
+
+ const welcomeEvent = {
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: {
+ cwd: "/tmp/workspace",
+ projectName: "workspace",
+ },
+ };
- const requestEnvelope = JSON.parse(sent) as { id: string };
socket.serverMessage(
JSON.stringify({
- id: requestEnvelope.id,
- result: { projects: [] },
+ _tag: "Chunk",
+ requestId: requestMessage.id,
+ values: [welcomeEvent],
}),
);
- await expect(requestPromise).resolves.toEqual({ projects: [] });
+ await waitFor(() => {
+ expect(listener).toHaveBeenCalledWith(welcomeEvent);
+ });
+ unsubscribe();
transport.dispose();
});
- it("drops malformed envelopes without crashing transport", () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ it("re-subscribes stream listeners after the stream exits", async () => {
const transport = new WsTransport("ws://localhost:3020");
+ const listener = vi.fn();
+
+ const unsubscribe = transport.subscribe(WS_METHODS.subscribeServerLifecycle, {}, listener);
+ await waitFor(() => {
+ expect(sockets).toHaveLength(1);
+ });
+
const socket = getSocket();
socket.open();
- const listener = vi.fn();
- transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
+ await waitFor(() => {
+ expect(socket.sent).toHaveLength(1);
+ });
- socket.serverMessage("{ invalid-json");
+ const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
socket.serverMessage(
JSON.stringify({
- type: "push",
- sequence: 2,
- channel: 42,
- data: { bad: true },
+ _tag: "Chunk",
+ requestId: firstRequest.id,
+ values: [
+ {
+ version: 1,
+ sequence: 1,
+ type: "welcome",
+ payload: {
+ cwd: "/tmp/one",
+ projectName: "one",
+ },
+ },
+ ],
}),
);
socket.serverMessage(
JSON.stringify({
- type: "push",
- sequence: 3,
- channel: WS_CHANNELS.serverConfigUpdated,
- data: { issues: [], providers: [] },
+ _tag: "Exit",
+ requestId: firstRequest.id,
+ exit: {
+ _tag: "Success",
+ value: null,
+ },
}),
);
- expect(listener).toHaveBeenCalledTimes(1);
- expect(listener).toHaveBeenCalledWith({
- type: "push",
- sequence: 3,
- channel: WS_CHANNELS.serverConfigUpdated,
- data: { issues: [], providers: [] },
+ await waitFor(() => {
+ const nextRequest = socket.sent
+ .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
+ .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
+ expect(nextRequest).toBeDefined();
});
- expect(warnSpy).toHaveBeenCalledTimes(2);
- expect(warnSpy).toHaveBeenNthCalledWith(
- 1,
- "Dropped inbound WebSocket envelope",
- "SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)",
- );
- expect(warnSpy).toHaveBeenNthCalledWith(
- 2,
- "Dropped inbound WebSocket envelope",
- expect.stringContaining('Expected "server.configUpdated"'),
- );
-
- transport.dispose();
- });
-
- it("queues requests until the websocket opens", async () => {
- const transport = new WsTransport("ws://localhost:3020");
- const socket = getSocket();
- const requestPromise = transport.request("projects.list");
- expect(socket.sent).toHaveLength(0);
-
- socket.open();
- expect(socket.sent).toHaveLength(1);
- const requestEnvelope = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
+ const secondRequest = socket.sent
+ .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string })
+ .find(
+ (message): message is { _tag: "Request"; id: string; tag: string } =>
+ message._tag === "Request" && message.id !== firstRequest.id,
+ );
+ if (!secondRequest) {
+ throw new Error("Expected a resubscribe request");
+ }
+ expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle);
+ expect(secondRequest.id).not.toBe(firstRequest.id);
+
+ const secondEvent = {
+ version: 1,
+ sequence: 2,
+ type: "welcome",
+ payload: {
+ cwd: "/tmp/two",
+ projectName: "two",
+ },
+ };
socket.serverMessage(
JSON.stringify({
- id: requestEnvelope.id,
- result: { projects: [] },
+ _tag: "Chunk",
+ requestId: secondRequest.id,
+ values: [secondEvent],
}),
);
- await expect(requestPromise).resolves.toEqual({ projects: [] });
+ await waitFor(() => {
+ expect(listener).toHaveBeenLastCalledWith(secondEvent);
+ });
+
+ unsubscribe();
transport.dispose();
});
});
diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts
index 46c74d9090..da22135a2f 100644
--- a/apps/web/src/wsTransport.ts
+++ b/apps/web/src/wsTransport.ts
@@ -1,43 +1,23 @@
-import {
- type WsPush,
- type WsPushChannel,
- type WsPushMessage,
- WebSocketResponse,
- type WsResponse as WsResponseMessage,
- WsResponse as WsResponseSchema,
-} from "@t3tools/contracts";
-import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
-import { Result, Schema } from "effect";
+import { Data, Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect";
+import { WsRpcGroup } from "@t3tools/contracts";
+import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
+import * as Socket from "effect/unstable/socket/Socket";
-type PushListener = (message: WsPushMessage) => void;
+const makeWsRpcClient = RpcClient.make(WsRpcGroup);
-interface PendingRequest {
- resolve: (result: unknown) => void;
- reject: (error: Error) => void;
- timeout: ReturnType;
-}
+type RpcClientFactory = typeof makeWsRpcClient;
+type WsRpcClient = RpcClientFactory extends Effect.Effect ? Client : never;
+type WsRpcClientMethods = Record unknown>;
interface SubscribeOptions {
- readonly replayLatest?: boolean;
+ readonly retryDelayMs?: number;
}
-type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";
-
-const REQUEST_TIMEOUT_MS = 60_000;
-const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
-const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema);
-const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
-
-const isWsPushMessage = (value: WsResponseMessage): value is WsPush =>
- "type" in value && value.type === "push";
+const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = 250;
-interface WsRequestEnvelope {
- id: string;
- body: {
- _tag: string;
- [key: string]: unknown;
- };
-}
+class WsTransportStreamMethodError extends Data.TaggedError("WsTransportStreamMethodError")<{
+ readonly method: string;
+}> {}
function asError(value: unknown, fallback: string): Error {
if (value instanceof Error) {
@@ -46,240 +26,136 @@ function asError(value: unknown, fallback: string): Error {
return new Error(fallback);
}
+function formatErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message.trim().length > 0) {
+ return error.message;
+ }
+ return String(error);
+}
+
+function resolveWebSocketUrl(url?: string): string {
+ const bridgeUrl = window.desktopBridge?.getWsUrl();
+ const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
+ const rawUrl =
+ url ??
+ (bridgeUrl && bridgeUrl.length > 0
+ ? bridgeUrl
+ : envUrl && envUrl.length > 0
+ ? envUrl
+ : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`);
+
+ const parsedUrl = new URL(rawUrl);
+ if (parsedUrl.pathname === "/" || parsedUrl.pathname.length === 0) {
+ parsedUrl.pathname = "/ws";
+ }
+ return parsedUrl.toString();
+}
+
export class WsTransport {
- private ws: WebSocket | null = null;
- private nextId = 1;
- private readonly pending = new Map();
- private readonly listeners = new Map void>>();
- private readonly latestPushByChannel = new Map();
- private readonly outboundQueue: string[] = [];
- private reconnectAttempt = 0;
- private reconnectTimer: ReturnType | null = null;
+ private readonly runtime: ManagedRuntime.ManagedRuntime;
+ private readonly clientScope: Scope.Closeable;
+ private readonly clientPromise: Promise;
private disposed = false;
- private state: TransportState = "connecting";
- private readonly url: string;
constructor(url?: string) {
- const bridgeUrl = window.desktopBridge?.getWsUrl();
- const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
- this.url =
- url ??
- (bridgeUrl && bridgeUrl.length > 0
- ? bridgeUrl
- : envUrl && envUrl.length > 0
- ? envUrl
- : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`);
- this.connect();
+ const resolvedUrl = resolveWebSocketUrl(url);
+ const runtimeLayer = RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Socket.layerWebSocket(resolvedUrl).pipe(
+ Layer.provide(Socket.layerWebSocketConstructorGlobal),
+ ),
+ RpcSerialization.layerJson,
+ ),
+ ),
+ );
+
+ this.runtime = ManagedRuntime.make(runtimeLayer);
+ this.clientScope = Effect.runSync(Scope.make());
+ this.clientPromise = this.runtime.runPromise(Scope.provide(this.clientScope)(makeWsRpcClient));
}
async request(method: string, params?: unknown): Promise {
+ if (this.disposed) {
+ throw new Error("Transport disposed");
+ }
if (typeof method !== "string" || method.length === 0) {
throw new Error("Request method is required");
}
- const id = String(this.nextId++);
- const body = params != null ? { ...params, _tag: method } : { _tag: method };
- const message: WsRequestEnvelope = { id, body };
- const encoded = JSON.stringify(message);
-
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- this.pending.delete(id);
- reject(new Error(`Request timed out: ${method}`));
- }, REQUEST_TIMEOUT_MS);
-
- this.pending.set(id, {
- resolve: resolve as (result: unknown) => void,
- reject,
- timeout,
- });
-
- this.send(encoded);
- });
+ try {
+ const client = await this.clientPromise;
+ const handler = (client as WsRpcClientMethods)[method];
+ if (typeof handler !== "function") {
+ throw new Error(`Unknown RPC method: ${method}`);
+ }
+ return (await Effect.runPromise(
+ Effect.suspend(() => handler(params ?? {}) as Effect.Effect),
+ )) as T;
+ } catch (error) {
+ throw asError(error, `Request failed: ${method}`);
+ }
}
- subscribe(
- channel: C,
- listener: PushListener,
+ subscribe(
+ method: string,
+ params: unknown,
+ listener: (value: T) => void,
options?: SubscribeOptions,
): () => void {
- let channelListeners = this.listeners.get(channel);
- if (!channelListeners) {
- channelListeners = new Set<(message: WsPush) => void>();
- this.listeners.set(channel, channelListeners);
+ if (this.disposed) {
+ return () => undefined;
}
- const wrappedListener = (message: WsPush) => {
- listener(message as WsPushMessage);
- };
- channelListeners.add(wrappedListener);
-
- if (options?.replayLatest) {
- const latest = this.latestPushByChannel.get(channel);
- if (latest) {
- wrappedListener(latest);
- }
- }
+ let active = true;
+ const retryDelayMs = options?.retryDelayMs ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS;
+ const cancel = Effect.runCallback(
+ Effect.promise(() => this.clientPromise).pipe(
+ Effect.flatMap((client) => {
+ const handler = (client as WsRpcClientMethods)[method];
+ if (typeof handler !== "function") {
+ return Effect.fail(new WsTransportStreamMethodError({ method }));
+ }
+ return Stream.runForEach(handler(params ?? {}) as Stream.Stream, (value) =>
+ Effect.sync(() => {
+ if (!active) {
+ return;
+ }
+ try {
+ listener(value);
+ } catch {
+ // Swallow listener errors so the stream stays live.
+ }
+ }),
+ );
+ }),
+ Effect.catch((error) => {
+ if (!active || this.disposed) {
+ return Effect.interrupt;
+ }
+ return Effect.sync(() => {
+ console.warn("WebSocket RPC subscription disconnected", {
+ method,
+ error: formatErrorMessage(error),
+ });
+ }).pipe(Effect.andThen(Effect.sleep(`${retryDelayMs} millis`)));
+ }),
+ Effect.forever,
+ ),
+ );
return () => {
- channelListeners?.delete(wrappedListener);
- if (channelListeners?.size === 0) {
- this.listeners.delete(channel);
- }
+ active = false;
+ cancel();
};
}
- getLatestPush(channel: C): WsPushMessage | null {
- const latest = this.latestPushByChannel.get(channel);
- return latest ? (latest as WsPushMessage) : null;
- }
-
- getState(): TransportState {
- return this.state;
- }
-
dispose() {
- this.disposed = true;
- this.state = "disposed";
- if (this.reconnectTimer !== null) {
- clearTimeout(this.reconnectTimer);
- this.reconnectTimer = null;
- }
- for (const pending of this.pending.values()) {
- clearTimeout(pending.timeout);
- pending.reject(new Error("Transport disposed"));
- }
- this.pending.clear();
- this.outboundQueue.length = 0;
- this.ws?.close();
- this.ws = null;
- }
-
- private connect() {
if (this.disposed) {
return;
}
-
- this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
- const ws = new WebSocket(this.url);
-
- ws.addEventListener("open", () => {
- this.ws = ws;
- this.state = "open";
- this.reconnectAttempt = 0;
- this.flushQueue();
- });
-
- ws.addEventListener("message", (event) => {
- this.handleMessage(event.data);
- });
-
- ws.addEventListener("close", () => {
- if (this.ws === ws) {
- this.ws = null;
- }
- if (this.disposed) {
- this.state = "disposed";
- return;
- }
- this.state = "closed";
- this.scheduleReconnect();
- });
-
- ws.addEventListener("error", (event) => {
- // Log WebSocket errors for debugging (close event will follow)
- console.warn("WebSocket connection error", { type: event.type, url: this.url });
- });
- }
-
- private handleMessage(raw: unknown) {
- const result = decodeWsResponse(raw);
- if (Result.isFailure(result)) {
- console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure));
- return;
- }
-
- const message = result.success;
- if (isWsPushMessage(message)) {
- this.latestPushByChannel.set(message.channel, message);
- const channelListeners = this.listeners.get(message.channel);
- if (channelListeners) {
- for (const listener of channelListeners) {
- try {
- listener(message);
- } catch {
- // Swallow listener errors
- }
- }
- }
- return;
- }
-
- if (!isWebSocketResponseEnvelope(message)) {
- return;
- }
-
- const pending = this.pending.get(message.id);
- if (!pending) {
- return;
- }
-
- clearTimeout(pending.timeout);
- this.pending.delete(message.id);
-
- if (message.error) {
- pending.reject(new Error(message.error.message));
- return;
- }
-
- pending.resolve(message.result);
- }
-
- private send(encodedMessage: string) {
- if (this.disposed) {
- return;
- }
-
- this.outboundQueue.push(encodedMessage);
- try {
- this.flushQueue();
- } catch {
- // Swallow: flushQueue has queued the message for retry on reconnect
- }
- }
-
- private flushQueue() {
- if (this.ws?.readyState !== WebSocket.OPEN) {
- return;
- }
-
- while (this.outboundQueue.length > 0) {
- const message = this.outboundQueue.shift();
- if (!message) {
- continue;
- }
- try {
- this.ws.send(message);
- } catch (error) {
- this.outboundQueue.unshift(message);
- throw asError(error, "Failed to send WebSocket request.");
- }
- }
- }
-
- private scheduleReconnect() {
- if (this.disposed || this.reconnectTimer !== null) {
- return;
- }
-
- const delay =
- RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ??
- RECONNECT_DELAYS_MS[0]!;
-
- this.reconnectAttempt += 1;
- this.reconnectTimer = setTimeout(() => {
- this.reconnectTimer = null;
- this.connect();
- }, delay);
+ this.disposed = true;
+ void Effect.runPromise(Scope.close(this.clientScope, Exit.void));
+ void this.runtime.dispose();
}
}
diff --git a/bun.lock b/bun.lock
index 4e1959c157..7d563cd0b9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -45,7 +45,7 @@
"name": "t3",
"version": "0.0.13",
"bin": {
- "t3": "./dist/index.mjs",
+ "t3": "./dist/bin.mjs",
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.77",
@@ -59,6 +59,7 @@
},
"devDependencies": {
"@effect/language-service": "catalog:",
+ "@effect/platform-bun": "catalog:",
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
@@ -173,12 +174,13 @@
},
"catalog": {
"@effect/language-service": "0.75.1",
- "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",
- "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",
- "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b",
+ "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553",
+ "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553",
+ "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553",
+ "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553",
"@types/bun": "^1.3.9",
"@types/node": "^24.10.13",
- "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",
+ "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553",
"tsdown": "^0.20.3",
"typescript": "^5.7.3",
"vitest": "^4.0.0",
@@ -266,13 +268,15 @@
"@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="],
- "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }],
+ "@effect/platform-bun": ["@effect/platform-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }],
- "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }],
+ "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32", "ioredis": "^5.7.0" } }],
- "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }],
+ "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@88a7553085a8e6e9d456a496a6075e1e573e6d01", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.32" } }],
- "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }],
+ "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553", { "peerDependencies": { "effect": "^4.0.0-beta.32" } }],
+
+ "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553", { "peerDependencies": { "effect": "^4.0.0-beta.32", "vitest": "^3.0.0 || ^4.0.0" } }],
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
@@ -1014,7 +1018,7 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
- "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }],
+ "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }],
"electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="],
@@ -1909,7 +1913,6 @@
"@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="],
"@effect/vitest/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="],
-
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
diff --git a/package.json b/package.json
index 02e71cf097..760bb8f1e4 100644
--- a/package.json
+++ b/package.json
@@ -8,10 +8,11 @@
"scripts"
],
"catalog": {
- "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",
- "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",
- "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",
- "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b",
+ "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@88a7553",
+ "@effect/platform-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-bun@88a7553",
+ "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@88a7553",
+ "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@88a7553",
+ "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@88a7553",
"@effect/language-service": "0.75.1",
"@types/bun": "^1.3.9",
"@types/node": "^24.10.13",
diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts
index 0ebd4fe5ae..a25da64f40 100644
--- a/packages/contracts/src/editor.ts
+++ b/packages/contracts/src/editor.ts
@@ -17,3 +17,8 @@ export const OpenInEditorInput = Schema.Struct({
editor: EditorId,
});
export type OpenInEditorInput = typeof OpenInEditorInput.Type;
+
+export class OpenError extends Schema.TaggedErrorClass()("OpenError", {
+ message: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index e64ca13d72..0c84ab8c67 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -211,3 +211,41 @@ export const GitPullResult = Schema.Struct({
upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
});
export type GitPullResult = typeof GitPullResult.Type;
+
+// RPC / domain errors
+export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", {
+ operation: Schema.String,
+ command: Schema.String,
+ cwd: Schema.String,
+ detail: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
+
+export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", {
+ operation: Schema.String,
+ detail: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
+
+export class TextGenerationError extends Schema.TaggedErrorClass()(
+ "TextGenerationError",
+ {
+ operation: Schema.String,
+ detail: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
+
+export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", {
+ operation: Schema.String,
+ detail: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
+
+export const GitManagerServiceError = Schema.Union([
+ GitManagerError,
+ GitCommandError,
+ GitHubCliError,
+ TextGenerationError,
+]);
+export type GitManagerServiceError = typeof GitManagerServiceError.Type;
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 0f37a93515..7c47d02bd9 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -11,3 +11,4 @@ export * from "./git";
export * from "./orchestration";
export * from "./editor";
export * from "./project";
+export * from "./wsRpc";
diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts
index 1b99362c53..09ac0d175b 100644
--- a/packages/contracts/src/keybindings.test.ts
+++ b/packages/contracts/src/keybindings.test.ts
@@ -9,39 +9,27 @@ import {
ResolvedKeybindingsConfig,
} from "./keybindings";
-const decode = (
- schema: S,
- input: unknown,
-): Effect.Effect, Schema.SchemaError, never> =>
- Schema.decodeUnknownEffect(schema as never)(input) as Effect.Effect<
- Schema.Schema.Type,
- Schema.SchemaError,
- never
- >;
-
-const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never);
-
it.effect("parses keybinding rules", () =>
Effect.gen(function* () {
- const parsed = yield* decode(KeybindingRule, {
+ const parsed = yield* Schema.decodeUnknownEffect(KeybindingRule)({
key: "mod+j",
command: "terminal.toggle",
});
assert.strictEqual(parsed.command, "terminal.toggle");
- const parsedClose = yield* decode(KeybindingRule, {
+ const parsedClose = yield* Schema.decodeUnknownEffect(KeybindingRule)({
key: "mod+w",
command: "terminal.close",
});
assert.strictEqual(parsedClose.command, "terminal.close");
- const parsedDiffToggle = yield* decode(KeybindingRule, {
+ const parsedDiffToggle = yield* Schema.decodeUnknownEffect(KeybindingRule)({
key: "mod+d",
command: "diff.toggle",
});
assert.strictEqual(parsedDiffToggle.command, "diff.toggle");
- const parsedLocal = yield* decode(KeybindingRule, {
+ const parsedLocal = yield* Schema.decodeUnknownEffect(KeybindingRule)({
key: "mod+shift+n",
command: "chat.newLocal",
});
@@ -50,20 +38,19 @@ it.effect("parses keybinding rules", () =>
);
it.effect("rejects invalid command values", () =>
+ // oxlint-disable-next-line require-yield
Effect.gen(function* () {
- const result = yield* Effect.exit(
- decode(KeybindingRule, {
- key: "mod+j",
- command: "script.Test.run",
- }),
- );
+ const result = Schema.decodeUnknownExit(KeybindingRule)({
+ key: "mod+j",
+ command: "script.Test.run",
+ });
assert.strictEqual(result._tag, "Failure");
}),
);
it.effect("accepts dynamic script run commands", () =>
Effect.gen(function* () {
- const parsed = yield* decode(KeybindingRule, {
+ const parsed = yield* Schema.decodeUnknownExit(KeybindingRule)({
key: "mod+r",
command: "script.setup.run",
});
@@ -73,7 +60,7 @@ it.effect("accepts dynamic script run commands", () =>
it.effect("parses keybindings array payload", () =>
Effect.gen(function* () {
- const parsed = yield* decode(KeybindingsConfig, [
+ const parsed = yield* Schema.decodeUnknownExit(KeybindingsConfig)([
{ key: "mod+j", command: "terminal.toggle" },
{ key: "mod+d", command: "terminal.split", when: "terminalFocus" },
]);
@@ -83,7 +70,7 @@ it.effect("parses keybindings array payload", () =>
it.effect("parses resolved keybinding rules", () =>
Effect.gen(function* () {
- const parsed = yield* decode(ResolvedKeybindingRule, {
+ const parsed = yield* Schema.decodeUnknownExit(ResolvedKeybindingRule)({
command: "terminal.split",
shortcut: {
key: "d",
@@ -108,7 +95,7 @@ it.effect("parses resolved keybinding rules", () =>
it.effect("parses resolved keybindings arrays", () =>
Effect.gen(function* () {
- const parsed = yield* decode(ResolvedKeybindingsConfig, [
+ const parsed = yield* Schema.decodeUnknownExit(ResolvedKeybindingsConfig)([
{
command: "terminal.toggle",
shortcut: {
@@ -126,7 +113,7 @@ it.effect("parses resolved keybindings arrays", () =>
);
it.effect("drops unknown fields in resolved keybinding rules", () =>
- decodeResolvedRule({
+ Schema.decodeUnknownExit(ResolvedKeybindingRule)({
command: "terminal.toggle",
shortcut: {
key: "j",
@@ -139,9 +126,8 @@ it.effect("drops unknown fields in resolved keybinding rules", () =>
key: "mod+j",
}).pipe(
Effect.map((parsed) => {
- const view = parsed as Record;
- assert.strictEqual("key" in view, false);
- assert.strictEqual(view.command, "terminal.toggle");
+ assert.strictEqual("key" in parsed, false);
+ assert.strictEqual(parsed.command, "terminal.toggle");
}),
),
);
diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts
index 48821b1824..baf92e3381 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -64,24 +64,27 @@ export const KeybindingShortcut = Schema.Struct({
});
export type KeybindingShortcut = typeof KeybindingShortcut.Type;
-export const KeybindingWhenNode: Schema.Schema = Schema.Union([
+const KeybindingWhenNodeRef = Schema.suspend(
+ (): Schema.Codec => KeybindingWhenNode,
+);
+export const KeybindingWhenNode = Schema.Union([
Schema.Struct({
type: Schema.Literal("identifier"),
name: Schema.NonEmptyString,
}),
Schema.Struct({
type: Schema.Literal("not"),
- node: Schema.suspend((): Schema.Schema => KeybindingWhenNode),
+ node: KeybindingWhenNodeRef,
}),
Schema.Struct({
type: Schema.Literal("and"),
- left: Schema.suspend((): Schema.Schema => KeybindingWhenNode),
- right: Schema.suspend((): Schema.Schema => KeybindingWhenNode),
+ left: KeybindingWhenNodeRef,
+ right: KeybindingWhenNodeRef,
}),
Schema.Struct({
type: Schema.Literal("or"),
- left: Schema.suspend((): Schema.Schema => KeybindingWhenNode),
- right: Schema.suspend((): Schema.Schema => KeybindingWhenNode),
+ left: KeybindingWhenNodeRef,
+ right: KeybindingWhenNodeRef,
}),
]);
export type KeybindingWhenNode =
@@ -101,3 +104,16 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch
Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
);
export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
+
+export class KeybindingsConfigError extends Schema.TaggedErrorClass()(
+ "KeybindingsConfigParseError",
+ {
+ configPath: Schema.String,
+ detail: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {
+ override get message(): string {
+ return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
+ }
+}
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 3208adc8bb..606f45b287 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -1005,3 +1005,43 @@ export const OrchestrationRpcSchemas = {
output: OrchestrationReplayEventsResult,
},
} as const;
+
+export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()(
+ "OrchestrationGetSnapshotError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
+
+export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()(
+ "OrchestrationDispatchCommandError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
+
+export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass()(
+ "OrchestrationGetTurnDiffError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
+
+export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass()(
+ "OrchestrationGetFullThreadDiffError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
+
+export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass()(
+ "OrchestrationReplayEventsError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts
index 0903253301..2851120d1d 100644
--- a/packages/contracts/src/project.ts
+++ b/packages/contracts/src/project.ts
@@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({
});
export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
+export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()(
+ "ProjectSearchEntriesError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
+
export const ProjectWriteFileInput = Schema.Struct({
cwd: TrimmedNonEmptyString,
relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),
@@ -37,3 +45,11 @@ export const ProjectWriteFileResult = Schema.Struct({
relativePath: TrimmedNonEmptyString,
});
export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
+
+export class ProjectWriteFileError extends Schema.TaggedErrorClass()(
+ "ProjectWriteFileError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {}
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index 96ea90c1f5..8af97920e3 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -1,5 +1,11 @@
import { Schema } from "effect";
-import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
+import {
+ IsoDateTime,
+ NonNegativeInt,
+ ProjectId,
+ ThreadId,
+ TrimmedNonEmptyString,
+} from "./baseSchemas";
import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
import { EditorId } from "./editor";
import { ProviderKind } from "./orchestration";
@@ -69,3 +75,76 @@ export const ServerConfigUpdatedPayload = Schema.Struct({
providers: ServerProviderStatuses,
});
export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
+
+export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({
+ issues: ServerConfigIssues,
+});
+export type ServerConfigKeybindingsUpdatedPayload =
+ typeof ServerConfigKeybindingsUpdatedPayload.Type;
+
+export const ServerConfigProviderStatusesPayload = Schema.Struct({
+ providers: ServerProviderStatuses,
+});
+export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type;
+
+export const ServerConfigStreamSnapshotEvent = Schema.Struct({
+ version: Schema.Literal(1),
+ type: Schema.Literal("snapshot"),
+ config: ServerConfig,
+});
+export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type;
+
+export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({
+ version: Schema.Literal(1),
+ type: Schema.Literal("keybindingsUpdated"),
+ payload: ServerConfigKeybindingsUpdatedPayload,
+});
+export type ServerConfigStreamKeybindingsUpdatedEvent =
+ typeof ServerConfigStreamKeybindingsUpdatedEvent.Type;
+
+export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({
+ version: Schema.Literal(1),
+ type: Schema.Literal("providerStatuses"),
+ payload: ServerConfigProviderStatusesPayload,
+});
+export type ServerConfigStreamProviderStatusesEvent =
+ typeof ServerConfigStreamProviderStatusesEvent.Type;
+
+export const ServerConfigStreamEvent = Schema.Union([
+ ServerConfigStreamSnapshotEvent,
+ ServerConfigStreamKeybindingsUpdatedEvent,
+ ServerConfigStreamProviderStatusesEvent,
+]);
+export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
+
+export const ServerLifecycleReadyPayload = Schema.Struct({
+ at: IsoDateTime,
+});
+export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type;
+
+export const ServerLifecycleStreamWelcomeEvent = Schema.Struct({
+ version: Schema.Literal(1),
+ sequence: NonNegativeInt,
+ type: Schema.Literal("welcome"),
+ payload: Schema.Struct({
+ cwd: TrimmedNonEmptyString,
+ projectName: TrimmedNonEmptyString,
+ bootstrapProjectId: Schema.optional(ProjectId),
+ bootstrapThreadId: Schema.optional(ThreadId),
+ }),
+});
+export type ServerLifecycleStreamWelcomeEvent = typeof ServerLifecycleStreamWelcomeEvent.Type;
+
+export const ServerLifecycleStreamReadyEvent = Schema.Struct({
+ version: Schema.Literal(1),
+ sequence: NonNegativeInt,
+ type: Schema.Literal("ready"),
+ payload: ServerLifecycleReadyPayload,
+});
+export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type;
+
+export const ServerLifecycleStreamEvent = Schema.Union([
+ ServerLifecycleStreamWelcomeEvent,
+ ServerLifecycleStreamReadyEvent,
+]);
+export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type;
diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts
index b0493d95c2..e51eaefaa4 100644
--- a/packages/contracts/src/terminal.ts
+++ b/packages/contracts/src/terminal.ts
@@ -149,3 +149,8 @@ export const TerminalEvent = Schema.Union([
TerminalActivityEvent,
]);
export type TerminalEvent = typeof TerminalEvent.Type;
+
+export class TerminalError extends Schema.TaggedErrorClass()("TerminalError", {
+ message: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts
index ebb76138b8..58c4375107 100644
--- a/packages/contracts/src/ws.ts
+++ b/packages/contracts/src/ws.ts
@@ -75,6 +75,13 @@ export const WS_METHODS = {
// Server meta
serverGetConfig: "server.getConfig",
serverUpsertKeybinding: "server.upsertKeybinding",
+
+ // Streaming subscriptions
+ subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents",
+ subscribeTerminalEvents: "subscribeTerminalEvents",
+ subscribeServerConfig: "subscribeServerConfig",
+ subscribeServerConfigUpdates: "subscribeServerConfigUpdates",
+ subscribeServerLifecycle: "subscribeServerLifecycle",
} as const;
// ── Push Event Channels ──────────────────────────────────────────────
@@ -227,6 +234,22 @@ export const WsPushEnvelopeBase = Schema.Struct({
});
export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type;
+export const SubscribeOrchestrationDomainEventsInput = Schema.Struct({});
+export type SubscribeOrchestrationDomainEventsInput =
+ typeof SubscribeOrchestrationDomainEventsInput.Type;
+
+export const SubscribeTerminalEventsInput = Schema.Struct({});
+export type SubscribeTerminalEventsInput = typeof SubscribeTerminalEventsInput.Type;
+
+export const SubscribeServerConfigInput = Schema.Struct({});
+export type SubscribeServerConfigInput = typeof SubscribeServerConfigInput.Type;
+
+export const SubscribeServerConfigUpdatesInput = Schema.Struct({});
+export type SubscribeServerConfigUpdatesInput = typeof SubscribeServerConfigUpdatesInput.Type;
+
+export const SubscribeServerLifecycleInput = Schema.Struct({});
+export type SubscribeServerLifecycleInput = typeof SubscribeServerLifecycleInput.Type;
+
// ── Union of all server → client messages ─────────────────────────────
export const WsResponse = Schema.Union([WebSocketResponse, WsPush]);
diff --git a/packages/contracts/src/wsRpc.ts b/packages/contracts/src/wsRpc.ts
new file mode 100644
index 0000000000..4b18b01671
--- /dev/null
+++ b/packages/contracts/src/wsRpc.ts
@@ -0,0 +1,288 @@
+import * as Rpc from "effect/unstable/rpc/Rpc";
+import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
+
+import { OpenError, OpenInEditorInput } from "./editor";
+import {
+ GitCheckoutInput,
+ GitCommandError,
+ GitCreateBranchInput,
+ GitCreateWorktreeInput,
+ GitCreateWorktreeResult,
+ GitInitInput,
+ GitListBranchesInput,
+ GitListBranchesResult,
+ GitManagerServiceError,
+ GitPreparePullRequestThreadInput,
+ GitPreparePullRequestThreadResult,
+ GitPullInput,
+ GitPullRequestRefInput,
+ GitPullResult,
+ GitRemoveWorktreeInput,
+ GitResolvePullRequestResult,
+ GitRunStackedActionInput,
+ GitRunStackedActionResult,
+ GitStatusInput,
+ GitStatusResult,
+} from "./git";
+import { KeybindingsConfigError } from "./keybindings";
+import {
+ ClientOrchestrationCommand,
+ OrchestrationEvent,
+ ORCHESTRATION_WS_METHODS,
+ OrchestrationDispatchCommandError,
+ OrchestrationGetFullThreadDiffError,
+ OrchestrationGetFullThreadDiffInput,
+ OrchestrationGetSnapshotError,
+ OrchestrationGetSnapshotInput,
+ OrchestrationGetTurnDiffError,
+ OrchestrationGetTurnDiffInput,
+ OrchestrationReplayEventsError,
+ OrchestrationReplayEventsInput,
+ OrchestrationRpcSchemas,
+} from "./orchestration";
+import {
+ ProjectSearchEntriesError,
+ ProjectSearchEntriesInput,
+ ProjectSearchEntriesResult,
+ ProjectWriteFileError,
+ ProjectWriteFileInput,
+ ProjectWriteFileResult,
+} from "./project";
+import {
+ TerminalClearInput,
+ TerminalCloseInput,
+ TerminalError,
+ TerminalEvent,
+ TerminalOpenInput,
+ TerminalResizeInput,
+ TerminalRestartInput,
+ TerminalSessionSnapshot,
+ TerminalWriteInput,
+} from "./terminal";
+import {
+ ServerConfigStreamEvent,
+ ServerLifecycleStreamEvent,
+ ServerUpsertKeybindingInput,
+ ServerUpsertKeybindingResult,
+} from "./server";
+import {
+ SubscribeOrchestrationDomainEventsInput,
+ SubscribeServerConfigInput,
+ SubscribeServerLifecycleInput,
+ SubscribeTerminalEventsInput,
+ WS_METHODS,
+} from "./ws";
+
+export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
+ payload: ServerUpsertKeybindingInput,
+ success: ServerUpsertKeybindingResult,
+ error: KeybindingsConfigError,
+});
+
+export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
+ payload: ProjectSearchEntriesInput,
+ success: ProjectSearchEntriesResult,
+ error: ProjectSearchEntriesError,
+});
+
+export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
+ payload: ProjectWriteFileInput,
+ success: ProjectWriteFileResult,
+ error: ProjectWriteFileError,
+});
+
+export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
+ payload: OpenInEditorInput,
+ error: OpenError,
+});
+
+export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, {
+ payload: GitStatusInput,
+ success: GitStatusResult,
+ error: GitManagerServiceError,
+});
+
+export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
+ payload: GitPullInput,
+ success: GitPullResult,
+ error: GitCommandError,
+});
+
+export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
+ payload: GitRunStackedActionInput,
+ success: GitRunStackedActionResult,
+ error: GitManagerServiceError,
+});
+
+export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, {
+ payload: GitPullRequestRefInput,
+ success: GitResolvePullRequestResult,
+ error: GitManagerServiceError,
+});
+
+export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, {
+ payload: GitPreparePullRequestThreadInput,
+ success: GitPreparePullRequestThreadResult,
+ error: GitManagerServiceError,
+});
+
+export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, {
+ payload: GitListBranchesInput,
+ success: GitListBranchesResult,
+ error: GitCommandError,
+});
+
+export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, {
+ payload: GitCreateWorktreeInput,
+ success: GitCreateWorktreeResult,
+ error: GitCommandError,
+});
+
+export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, {
+ payload: GitRemoveWorktreeInput,
+ error: GitCommandError,
+});
+
+export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, {
+ payload: GitCreateBranchInput,
+ error: GitCommandError,
+});
+
+export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, {
+ payload: GitCheckoutInput,
+ error: GitCommandError,
+});
+
+export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, {
+ payload: GitInitInput,
+ error: GitCommandError,
+});
+
+export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
+ payload: TerminalOpenInput,
+ success: TerminalSessionSnapshot,
+ error: TerminalError,
+});
+
+export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
+ payload: TerminalWriteInput,
+ error: TerminalError,
+});
+
+export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, {
+ payload: TerminalResizeInput,
+ error: TerminalError,
+});
+
+export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, {
+ payload: TerminalClearInput,
+ error: TerminalError,
+});
+
+export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, {
+ payload: TerminalRestartInput,
+ success: TerminalSessionSnapshot,
+ error: TerminalError,
+});
+
+export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
+ payload: TerminalCloseInput,
+ error: TerminalError,
+});
+
+export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, {
+ payload: OrchestrationGetSnapshotInput,
+ success: OrchestrationRpcSchemas.getSnapshot.output,
+ error: OrchestrationGetSnapshotError,
+});
+
+export const WsOrchestrationDispatchCommandRpc = Rpc.make(
+ ORCHESTRATION_WS_METHODS.dispatchCommand,
+ {
+ payload: ClientOrchestrationCommand,
+ success: OrchestrationRpcSchemas.dispatchCommand.output,
+ error: OrchestrationDispatchCommandError,
+ },
+);
+
+export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {
+ payload: OrchestrationGetTurnDiffInput,
+ success: OrchestrationRpcSchemas.getTurnDiff.output,
+ error: OrchestrationGetTurnDiffError,
+});
+
+export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
+ ORCHESTRATION_WS_METHODS.getFullThreadDiff,
+ {
+ payload: OrchestrationGetFullThreadDiffInput,
+ success: OrchestrationRpcSchemas.getFullThreadDiff.output,
+ error: OrchestrationGetFullThreadDiffError,
+ },
+);
+
+export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, {
+ payload: OrchestrationReplayEventsInput,
+ success: OrchestrationRpcSchemas.replayEvents.output,
+ error: OrchestrationReplayEventsError,
+});
+
+export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make(
+ WS_METHODS.subscribeOrchestrationDomainEvents,
+ {
+ payload: SubscribeOrchestrationDomainEventsInput,
+ success: OrchestrationEvent,
+ stream: true,
+ },
+);
+
+export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, {
+ payload: SubscribeTerminalEventsInput,
+ success: TerminalEvent,
+ stream: true,
+});
+
+export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {
+ payload: SubscribeServerConfigInput,
+ success: ServerConfigStreamEvent,
+ error: KeybindingsConfigError,
+ stream: true,
+});
+
+export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, {
+ payload: SubscribeServerLifecycleInput,
+ success: ServerLifecycleStreamEvent,
+ stream: true,
+});
+
+export const WsRpcGroup = RpcGroup.make(
+ WsServerUpsertKeybindingRpc,
+ WsProjectsSearchEntriesRpc,
+ WsProjectsWriteFileRpc,
+ WsShellOpenInEditorRpc,
+ WsGitStatusRpc,
+ WsGitPullRpc,
+ WsGitRunStackedActionRpc,
+ WsGitResolvePullRequestRpc,
+ WsGitPreparePullRequestThreadRpc,
+ WsGitListBranchesRpc,
+ WsGitCreateWorktreeRpc,
+ WsGitRemoveWorktreeRpc,
+ WsGitCreateBranchRpc,
+ WsGitCheckoutRpc,
+ WsGitInitRpc,
+ WsTerminalOpenRpc,
+ WsTerminalWriteRpc,
+ WsTerminalResizeRpc,
+ WsTerminalClearRpc,
+ WsTerminalRestartRpc,
+ WsTerminalCloseRpc,
+ WsSubscribeOrchestrationDomainEventsRpc,
+ WsSubscribeTerminalEventsRpc,
+ WsSubscribeServerConfigRpc,
+ WsSubscribeServerLifecycleRpc,
+ WsOrchestrationGetSnapshotRpc,
+ WsOrchestrationDispatchCommandRpc,
+ WsOrchestrationGetTurnDiffRpc,
+ WsOrchestrationGetFullThreadDiffRpc,
+ WsOrchestrationReplayEventsRpc,
+);