| 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. |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | |
| 6 | import { 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 | */ |
| 21 | const CURSOR_SHELL_SPAWN_MARKERS = [ |
| 22 | "dump_zsh_state", |
| 23 | "dump_bash_state", |
| 24 | "__CURSOR_SANDBOX_ENV_RESTORE", |
| 25 | ] as const; |
| 26 | |
| 27 | export 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 | |
| 46 | function 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 | */ |
| 63 | export 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. */ |
| 73 | export 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 | ); |