Skip to content
Source
Sign inSign up

CursorSdk.ts

149 lines5.2 KB

1// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- The guard runs in Node's unhandled-rejection callback, outside an Effect runtime. stderr must match Node's default unhandled-rejection print.
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9​
10import { loadCursorSdk, loadCursorSqlite } from "./sdk.ts";
11​
12// The SDK leaves a shell-error promise unhandled, which would exit the server.
13const CURSOR_SHELL_SPAWN_MARKERS = [
14 "dump_zsh_state",
15 "dump_bash_state",
16 "__CURSOR_SANDBOX_ENV_RESTORE",
17] as const;
18​
19function isCursorShellSpawnFailure(reason: unknown): boolean {
20 if (typeof reason !== "object" || reason === null) {
21 return false;
22 }
23 const syscall = Reflect.get(reason, "syscall");
24 const code = Reflect.get(reason, "code");
25 const spawnargs = Reflect.get(reason, "spawnargs");
26 if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") {
27 return false;
28 }
29 if (!Array.isArray(spawnargs)) {
30 return false;
31 }
32 return spawnargs.some(
33 (arg) =>
34 typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)),
35 );
37​
38/**
39 * The SDK writes final frames from promises nothing awaits. A closed stream rejects them
40 * with WriteIterableClosedError; an ended request body rejects them with the Aborted
41 * ConnectError its connect-node pump wraps around ERR_STREAM_WRITE_AFTER_END. The classes
42 * are minified, so only `name` identifies them.
43 */
44function isCursorClosedStreamWrite(reason: unknown): boolean {
45 if (!(reason instanceof Error)) {
46 return false;
47 }
48 if (reason.name === "WriteIterableClosedError") {
49 return true;
50 }
51 return (
52 reason.name === "ConnectError" &&
53 reason.cause instanceof Error &&
54 Reflect.get(reason.cause, "code") === "ERR_STREAM_WRITE_AFTER_END"
55 );
57​
58function onUnhandledRejection(reason: unknown): void {
59 if (isCursorShellSpawnFailure(reason)) {
60 console.error("Cursor shell spawn failed. The server will keep running.", reason);
61 return;
62 }
63 if (isCursorClosedStreamWrite(reason)) {
64 console.error("Cursor SDK wrote to a closed stream. The server will keep running.");
65 return;
66 }
67 if (process.listenerCount("unhandledRejection") > 1) {
68 return;
69 }
70 console.error(reason);
71 process.exit(1);
73​
74const decodeCursorCliConfig = Schema.decodeEffect(
75 Schema.fromJsonString(
76 Schema.Struct({
77 network: Schema.optionalKey(
78 Schema.Struct({ useHttp1ForAgent: Schema.optionalKey(Schema.Boolean) }),
79 ),
80 }),
81 ),
82);
83​
84// Proxies without HTTP/2 need this Cursor CLI setting, which the SDK never reads itself.
85const readCursorCliUseHttp1ForAgent = Effect.gen(function* () {
86 const fileSystem = yield* FileSystem.FileSystem;
87 const path = yield* Path.Path;
88 const { CURSOR_CONFIG_DIR, XDG_CONFIG_HOME } = yield* HostProcess.Environment;
89 const configDirectory = CURSOR_CONFIG_DIR?.trim()
90 ? CURSOR_CONFIG_DIR
91 : XDG_CONFIG_HOME?.trim()
92 ? path.join(XDG_CONFIG_HOME, "cursor")
93 : path.join(yield* HostProcess.HomeDirectory, ".cursor");
94 const configPath = path.join(configDirectory, "cli-config.json");
95 const config = yield* fileSystem.readFileString(configPath).pipe(
96 Effect.flatMap(decodeCursorCliConfig),
97 Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
98 Effect.catch(() =>
99 Effect.logWarning("Cursor CLI config could not be read; using the default agent transport", {
100 path: configPath,
101 }).pipe(Effect.as(undefined)),
102 ),
103 );
104 return config?.network?.useHttp1ForAgent === true;
105}).pipe(Effect.withSpan("CursorSdk.readCliUseHttp1ForAgent"));
106​
107/** Keeps the shell spawn guard armed while in-process agents run. */
108export class CursorSdk extends Context.Service<
109 CursorSdk,
110 {
111 readonly load: typeof loadCursorSdk;
112 readonly loadSqlite: typeof loadCursorSqlite;
113 }
114>()("@t3tools/provider-cursor/server/CursorSdk") {}
115​
116/** Provide once per process: a second guard would defer to the first and swallow real rejections. */
117export const layer = Layer.effect(
118 CursorSdk,
119 Effect.gen(function* () {
120 yield* Effect.acquireRelease(
121 Effect.sync(() => {
122 process.on("unhandledRejection", onUnhandledRejection);
123 }),
124 () =>
125 Effect.sync(() => {
126 process.off("unhandledRejection", onUnhandledRejection);
127 }),
128 );
129 // The SDK picks the agent transport per process, so no provider instance can own this.
130 const useHttp1ForAgent = yield* readCursorCliUseHttp1ForAgent;
131 let configured = false;
132 yield* Effect.addFinalizer(() =>
133 Effect.sync(() => {
134 if (configured) loadCursorSdk().Cursor.configure({ local: { useHttp1ForAgent: null } });
135 }),
136 );
137 return CursorSdk.of({
138 load: () => {
139 const sdk = loadCursorSdk();
140 if (useHttp1ForAgent && !configured) {
141 sdk.Cursor.configure({ local: { useHttp1ForAgent: true } });
142 configured = true;
143 }
144 return sdk;
145 },
146 loadSqlite: loadCursorSqlite,
147 });
148 }),
149);