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 Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { Agent, createAgentPlatform, SqliteLocalAgentStore } from "./sdk.ts";
7
8/**
9 * The Cursor Agent SDK runs in this process and spawns a shell for tool
10 * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`,
11 * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory
12 * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error,
13 * then rejects an internal promise nothing awaits. Node exits on that
14 * unhandled rejection and takes the server with it.
15 *
16 * The guard ignores only that rejection. Every other unhandled rejection
17 * still prints and exits when this is the process's only listener, matching
18 * Node's default. `child_process.spawn` itself is left alone, so git,
19 * terminals, and other providers keep their own error handling.
20 */
21const CURSOR_SHELL_SPAWN_MARKERS = [
22 "dump_zsh_state",
23 "dump_bash_state",
24 "__CURSOR_SANDBOX_ENV_RESTORE",
25] as const;
26
27export function isCursorShellSpawnFailure(reason: unknown): boolean {
28 if (typeof reason !== "object" || reason === null) {
29 return false;
30 }
31 const syscall = Reflect.get(reason, "syscall");
32 const code = Reflect.get(reason, "code");
33 const spawnargs = Reflect.get(reason, "spawnargs");
34 if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") {
35 return false;
36 }
37 if (!Array.isArray(spawnargs)) {
38 return false;
39 }
40 return spawnargs.some(
41 (arg) =>
42 typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)),
43 );
44}
45
46function onUnhandledRejection(reason: unknown): void {
47 if (isCursorShellSpawnFailure(reason)) {
48 console.error("Cursor shell spawn failed. The server will keep running.", reason);
49 return;
50 }
51 // Another handler registered besides this one owns the decision.
52 if (process.listenerCount("unhandledRejection") > 1) {
53 return;
54 }
55 console.error(reason);
56 process.exit(1);
57}
58
59/**
60 * The SDK entry points that run agents in this process. Reaching them through
61 * this service keeps the shell spawn guard armed while they are in use.
62 */
63export class CursorSdk extends Context.Service<
64 CursorSdk,
65 {
66 readonly Agent: typeof Agent;
67 readonly createAgentPlatform: typeof createAgentPlatform;
68 readonly SqliteLocalAgentStore: typeof SqliteLocalAgentStore;
69 }
70>()("@t3tools/provider-cursor/server/CursorSdk") {}
71
72/** Provide once per process: a second guard would defer to the first and swallow real rejections. */
73export const layer = Layer.effect(
74 CursorSdk,
75 Effect.acquireRelease(
76 Effect.sync(() => {
77 process.on("unhandledRejection", onUnhandledRejection);
78 }),
79 () =>
80 Effect.sync(() => {
81 process.off("unhandledRejection", onUnhandledRejection);
82 }),
83 ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform, SqliteLocalAgentStore }))),
84);