1// @effect-diagnostics globalTimers:off
2// The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists.
3// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.realPath is Node's JS realpath; museWorkspaceRoot needs the native one.
4import * as NodeFSP from "node:fs/promises";
5
6import {
7 spawnMspConnection,
8 type Connection,
9 type ProcessExit,
10 type SpawnedMspConnection,
11} from "@muse-code/sdk";
12import type { RuntimeMode } from "@t3tools/contracts";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14import { SpawnExecutableResolution } from "@t3tools/shared/shell";
15import * as Effect from "effect/Effect";
16
17export interface MuseSdkHost {
18 readonly connection: Pick<
19 Connection,
20 | "command"
21 | "request"
22 | "mintCommandId"
23 | "onNotification"
24 | "onServerRequest"
25 | "onProtocolError"
26 | "closed"
27 >;
28 readonly initializeResult: Pick<SpawnedMspConnection["initializeResult"], "grantedCapabilities">;
29 readonly exited: Promise<ProcessExit>;
30 /** The last lines Muse wrote to stderr, for explaining an unexpected exit. */
31 readonly stderrTail?: () => ReadonlyArray<string>;
32 readonly close: () => Promise<void>;
33}
34
35/** The program that runs `binaryPath`, and the arguments that go before the binary's own. */
36export interface MuseLaunch {
37 readonly command: string;
38 readonly args: ReadonlyArray<string>;
39}
40
41export interface MuseSdkHostOptions {
42 readonly binaryPath: string;
43 /** Arguments before the serve arguments, for a launcher in front of the binary. */
44 readonly launchArgs?: ReadonlyArray<string>;
45 /** How to start `binaryPath`, from `museLaunch`; without it the binary path is run as is. */
46 readonly launch?: MuseLaunch;
47 readonly cwd?: string;
48 readonly environment?: NodeJS.ProcessEnv;
49 readonly runtimeMode?: RuntimeMode;
50 /** Disables shell and filesystem writes and workspace trust. */
51 readonly readOnly?: boolean;
52 /** Read-only generation needs durable logging for turn/item notifications (verified through 1.1.1). */
53 readonly sessionLogging?: boolean;
54 readonly signal?: AbortSignal;
55 readonly startupTimeoutMs?: number;
56}
57
58/** Preserve the host's CLI login while excluding the API-key override before copying values. */
59export function makeMuseEnvironment(
60 environment: NodeJS.ProcessEnv = process.env,
61): NodeJS.ProcessEnv {
62 return {
63 ...Object.fromEntries(
64 Object.keys(environment)
65 .filter((key) => key.toUpperCase() !== "META_API_KEY")
66 .map((key) => [key, environment[key]]),
67 ),
68 MUSE_NO_AUTO_UPDATE: "1",
69 };
70}
71
72/** Windows' verbatim form of a canonical path: `\\?\C:\…`, or `\\?\UNC\server\share\…`. */
73export function museVerbatimPath(path: string): string {
74 if (path.startsWith("\\\\?\\")) return path;
75 return path.startsWith("\\\\") ? `\\\\?\\UNC\\${path.slice(2)}` : `\\\\?\\${path}`;
76}
77
78/**
79 * A workspace root in the form `turn/start` accepts. On Windows Muse rejects
80 * anything but the verbatim canonical path, true case and long names
81 * included, so resolve with the native realpath, which unlike
82 * FileSystem.realPath fixes case and expands 8.3 names. Muse itself still
83 * starts in the plain path: the `muse.cmd` launcher runs under cmd.exe, which
84 * cannot use a verbatim directory.
85 */
86export const museWorkspaceRoot = Effect.fn("museWorkspaceRoot")(function* (path: string) {
87 if ((yield* HostProcess.Platform) !== "win32") return path;
88 const canonical = yield* Effect.tryPromise(() => NodeFSP.realpath(path)).pipe(
89 // A verbatim path takes `/` literally, so an unresolved root needs Windows separators.
90 Effect.orElseSucceed(() => path.replaceAll("/", "\\")),
91 );
92 return museVerbatimPath(canonical);
93});
94
95/** A Windows environment variable; their names are case-insensitive there. */
96function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) {
97 const key = Object.keys(environment).find(
98 (candidate) => candidate.toUpperCase() === name.toUpperCase(),
99 );
100 return key === undefined ? undefined : environment[key];
101}
102
103/**
104 * How to start Muse. The SDK spawns without a shell, which finds the `muse`
105 * launcher on PATH everywhere but Windows. There Node neither looks a bare
106 * `muse` up with PATHEXT (ENOENT) nor runs a `.cmd` file such as Muse's own
107 * launcher (EINVAL) without a shell, so resolve the binary like any other
108 * command and run a `.cmd` or `.bat` under cmd.exe. Node quotes a path with
109 * spaces, and inside those quotes cmd takes every character literally. A path
110 * without spaces is not quoted, so its metacharacters are escaped instead.
111 */
112export const museLaunch = Effect.fn("museLaunch")(function* (
113 binaryPath: string,
114 environment?: NodeJS.ProcessEnv,
115): Effect.fn.Return<MuseLaunch> {
116 if ((yield* HostProcess.Platform) !== "win32") return { command: binaryPath, args: [] };
117 const env = environment ?? (yield* HostProcess.Environment);
118 const resolveExecutable = yield* SpawnExecutableResolution;
119 const resolved = resolveExecutable(binaryPath, "win32", env) ?? binaryPath;
120 if (!/\.(?:cmd|bat)$/i.test(resolved)) return { command: resolved, args: [] };
121 const systemRoot = windowsEnvironmentValue(env, "SystemRoot");
122 return {
123 command:
124 windowsEnvironmentValue(env, "ComSpec") ??
125 (systemRoot ? `${systemRoot}\\System32\\cmd.exe` : "cmd.exe"),
126 // /d skips cmd's AutoRun commands, which could write into Muse's stdout. cmd /c drops
127 // the quotes from a command that starts with one and holds `&`, `@`, `^` or a
128 // parenthesis, so a quoted path follows a bare `@` (echo off), which keeps them.
129 args: [
130 "/d",
131 "/c",
132 ...(/\s/.test(resolved) ? ["@", resolved] : [resolved.replace(/[()&<>@^|]/g, "^$&")]),
133 ],
134 };
135});
136
137export function museApprovalMode(runtimeMode: RuntimeMode) {
138 return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched";
139}
140
141/**
142 * The permission profile `session/start` and `session/resume` select. Without
143 * one Muse applies its own default, which can be `:auto-review`, where an AI
144 * reviewer decides eligible approvals instead of the user.
145 */
146export function musePermissionProfile(runtimeMode: RuntimeMode) {
147 return runtimeMode === "full-access" ? ":unrestricted" : ":ask-me";
148}
149
150/** The `muse serve` arguments for a host: read-only hosts get no shell, writes, or workspace trust. */
151export function museServeArgs(
152 options: Pick<MuseSdkHostOptions, "readOnly" | "sessionLogging" | "runtimeMode">,
153) {
154 const args = ["serve"];
155 if (options.readOnly) {
156 args.push("--disable-shell", "--disable-write");
157 if (options.sessionLogging !== true) args.push("--no-session-log");
158 } else {
159 args.push("--trust-workspace");
160 if (options.runtimeMode === "full-access") args.push("--disable-sandbox");
161 }
162 return args;
163}
164
165/** What T3 sends in MSP `initialize`; only full hosts ask for session MCP servers. */
166export function museInitializeParams(readOnly = false) {
167 return {
168 clientInfo: { name: "t3_code", title: "T3 Code", version: "1" },
169 capabilities: { requestedCapabilities: readOnly ? [] : ["sessionMcp"] },
170 };
171}
172
173/** One SDK-owned process. The pre-handshake handle owns cleanup even when initialize never replies. */
174export async function createMuseSdkHost(
175 options: MuseSdkHostOptions,
176 spawn: typeof spawnMspConnection = spawnMspConnection,
177): Promise<MuseSdkHost> {
178 options.signal?.throwIfAborted();
179 const launch = options.launch ?? { command: options.binaryPath, args: [] };
180 const handshake = spawn({
181 command: launch.command,
182 args: [...launch.args, ...(options.launchArgs ?? []), ...museServeArgs(options)],
183 ...(options.cwd ? { cwd: options.cwd } : {}),
184 // Callers pass an environment already built with makeMuseEnvironment.
185 env: options.environment ?? makeMuseEnvironment(),
186 // A healthy host exits at once; this only bounds a hung one on close.
187 shutdownTimeoutMs: 10_000,
188 });
189 let closePromise: Promise<void> | undefined;
190 const close = () => {
191 options.signal?.removeEventListener("abort", onAbort);
192 return (closePromise ??= handshake.close().then(() => undefined));
193 };
194 let rejectStartup: (reason: unknown) => void = () => {};
195 const onAbort = () => {
196 rejectStartup(options.signal?.reason ?? new Error("Muse SDK startup aborted."));
197 void close().catch(() => {});
198 };
199 const interrupted = new Promise<never>((_resolve, reject) => {
200 rejectStartup = reject;
201 });
202 options.signal?.addEventListener("abort", onAbort, { once: true });
203 if (options.signal?.aborted) onAbort();
204 const timer = setTimeout(
205 () => rejectStartup(new Error("Muse SDK initialization timed out.")),
206 options.startupTimeoutMs ?? 20_000,
207 );
208 timer.unref();
209 try {
210 const host = await Promise.race([
211 handshake.initialize(museInitializeParams(options.readOnly)),
212 interrupted,
213 ]);
214 if (host.initializeResult.schema?.version !== 1) {
215 throw new Error("Muse SDK returned an unsupported protocol envelope version.");
216 }
217 // The SDK's fingerprint warning permits additive optional schema changes.
218 // Successful initialization, not fingerprint identity, determines readiness.
219 return {
220 connection: host.connection,
221 initializeResult: host.initializeResult,
222 exited: host.exited,
223 stderrTail: () => handshake.child.stderrTail,
224 close,
225 };
226 } catch (error) {
227 await close();
228 throw error;
229 } finally {
230 clearTimeout(timer);
231 }
232}
233
234/** Cancellation waits for native shutdown before callers release startup resources. */
235export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(function* (
236 options: Omit<MuseSdkHostOptions, "signal">,
237 createHost: typeof createMuseSdkHost = createMuseSdkHost,
238) {
239 const launch = options.launch ?? (yield* museLaunch(options.binaryPath, options.environment));
240 let startup: Promise<MuseSdkHost> | undefined;
241 return yield* Effect.tryPromise((signal) => {
242 startup = createHost({ ...options, launch, signal });
243 return startup;
244 }).pipe(
245 Effect.onInterrupt(() =>
246 Effect.promise(async () => {
247 await startup?.then(
248 (host) => host.close(),
249 () => {},
250 );
251 }),
252 ),
253 );
254});