1import { ProjectId, type ProjectScript } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import {
4 projectScriptRuntimeEnv,
5 resolveProjectScripts,
6 settleProjectScript,
7 setupProjectScript,
8} from "@t3tools/shared/projectScripts";
9
10import * as Clock from "effect/Clock";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as Deferred from "effect/Deferred";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Schema from "effect/Schema";
18
19import * as ServerSettings from "../serverSettings.ts";
20import * as TerminalManager from "../terminal/Manager.ts";
21import * as ProjectService from "./ProjectService.ts";
22
23export interface ProjectSetupScriptRunnerResultNoScript {
24 readonly status: "no-script";
25}
26
27export interface ProjectSetupScriptRunnerResultStarted {
28 readonly status: "started";
29 readonly scriptId: string;
30 readonly scriptName: string;
31 readonly scriptCommand: string;
32 readonly terminalId: string;
33 readonly cwd: string;
34 /** False when the script's `async` flag asks the agent to wait for it. */
35 readonly async: boolean;
36 /**
37 * Resolves when the script's shell prints the completion sentinel. The
38 * exit code is null when the terminal exited or was closed before the
39 * sentinel arrived. Only present when `observeCompletion` was requested.
40 * An exit code of 0 closes the setup shell if it has nothing left running.
41 */
42 readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>;
43}
44
45export interface ProjectSetupScriptCompletion {
46 readonly exitCode: number | null;
47 readonly durationMs: number;
48}
49
50export interface ProjectSetupScriptOutputLine {
51 readonly line: string;
52}
53
54export type ProjectSetupScriptRunnerResult =
55 | ProjectSetupScriptRunnerResultNoScript
56 | ProjectSetupScriptRunnerResultStarted;
57
58export interface ProjectSetupScriptRunnerInput {
59 readonly threadId: string;
60 readonly projectId?: string;
61 readonly projectCwd?: string;
62 readonly worktreePath: string;
63 readonly preferredTerminalId?: string;
64 /** Which project script to run. Defaults to the worktree setup script. */
65 readonly trigger?: "setup" | "settle";
66 readonly project?: {
67 readonly id: ProjectId;
68 readonly workspaceRoot: string;
69 readonly scripts: ReadonlyArray<ProjectScript>;
70 };
71 /**
72 * Wrap the command so the shell reports its exit code back through the
73 * terminal stream, and forward cleaned output lines while it runs. The
74 * bootstrap flow uses this to drive the worktree setup card.
75 */
76 readonly observeCompletion?: {
77 readonly onOutputLine?: (line: string) => Effect.Effect<void>;
78 };
79}
80
81export class ProjectSetupScriptOperationError extends Schema.TaggedError<ProjectSetupScriptOperationError>()(
82 "ProjectSetupScriptOperationError",
83 {
84 threadId: Schema.String,
85 projectId: Schema.optional(Schema.String),
86 projectCwd: Schema.optional(Schema.String),
87 worktreePath: Schema.String,
88 operation: Schema.Literals(["resolveProject", "readSettings", "openTerminal", "writeCommand"]),
89 cause: Schema.Defect(),
90 },
91) {
92 override get message(): string {
93 return `Project setup script operation '${this.operation}' failed for thread '${this.threadId}' in '${this.worktreePath}'.`;
94 }
95}
96
97export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedError<ProjectSetupScriptProjectNotFoundError>()(
98 "ProjectSetupScriptProjectNotFoundError",
99 {
100 threadId: Schema.String,
101 projectId: Schema.optional(Schema.String),
102 projectCwd: Schema.optional(Schema.String),
103 worktreePath: Schema.String,
104 },
105) {
106 override get message(): string {
107 return `Project was not found for setup script execution for thread '${this.threadId}' in '${this.worktreePath}'.`;
108 }
109}
110
111export const ProjectSetupScriptRunnerError = Schema.Union([
112 ProjectSetupScriptOperationError,
113 ProjectSetupScriptProjectNotFoundError,
114]);
115export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type;
116
117export class ProjectSetupScriptRunner extends Context.Service<
118 ProjectSetupScriptRunner,
119 {
120 readonly runForThread: (
121 input: ProjectSetupScriptRunnerInput,
122 ) => Effect.Effect<ProjectSetupScriptRunnerResult, ProjectSetupScriptRunnerError>;
123 }
124>()("t3/project/ProjectSetupScriptRunner") {}
125
126/** @public Service construction is part of the canonical Effect module API. */
127/**
128 * Marker the wrapped setup command echoes so the exit code can be read from
129 * the PTY stream. Each run gets its own random token so script output cannot
130 * spoof completion, and the sentinel pattern is built per run from it.
131 */
132const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__";
133const OUTPUT_LINE_MAX_LENGTH = 400;
134/** A partial line longer than this is a byte stream, not a line. Keep only the tail. */
135const PARTIAL_LINE_MAX_LENGTH = 4_096;
136
137function completionSentinel(token: string): string {
138 return `${COMPLETION_SENTINEL_PREFIX}_${token}:`;
139}
140
141function completionSentinelPattern(token: string): RegExp {
142 return new RegExp(`${COMPLETION_SENTINEL_PREFIX}_${token}:(-?\\d+)`);
143}
144
145/** Removes ANSI escape sequences and cursor controls so lines can be shown as plain text. */
146function stripTerminalControl(text: string): string {
147 return (
148 text
149 .replace(
150 // eslint-disable-next-line no-control-regex -- ANSI escape sequences start with ESC.
151 /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g,
152 "",
153 )
154 // eslint-disable-next-line no-control-regex -- removing control characters is the point.
155 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
156 );
157}
158
159type CompletionShell = "posix" | "fish" | "powershell";
160
161/**
162 * Predicts the shell TerminalManager will spawn for the setup terminal. The
163 * manager takes `$SHELL` on POSIX and PowerShell on Windows, falling back to
164 * other shells only when that one fails to spawn.
165 */
166function resolveCompletionShell(
167 platform: NodeJS.Platform,
168 env: NodeJS.ProcessEnv,
169): CompletionShell {
170 if (platform === "win32") return "powershell";
171 const shell = env.SHELL ?? "";
172 const name = shell.split("/").at(-1) ?? shell;
173 if (name === "fish") return "fish";
174 if (name === "pwsh" || name === "powershell") return "powershell";
175 return "posix";
176}
177
178/**
179 * Builds the shell input for the setup script. The command runs inside a
180 * block and the block closes on its own line, so a trailing `# comment` or a
181 * heredoc terminator in the command cannot swallow the sentinel. The shell
182 * reads the whole block before running any of it, so a script that reads
183 * stdin cannot consume the sentinel line either. Lines are separated by `\r`
184 * because that is the Enter key for every shell's line editor.
185 */
186function wrapCommandForCompletion(
187 command: string,
188 shell: CompletionShell,
189 sentinel: string,
190): string {
191 const body = command.replace(/\r?\n/g, "\r");
192 switch (shell) {
193 case "powershell":
194 return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`;
195 case "fish":
196 return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`;
197 case "posix":
198 return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`;
199 }
200}
201
202/** @public Service construction is part of the canonical Effect module API. */
203export const make = Effect.gen(function* () {
204 const projects = yield* ProjectService.ProjectService;
205 const terminalManager = yield* TerminalManager.TerminalManager;
206 const serverSettings = yield* ServerSettings.ServerSettingsService;
207 const crypto = yield* Crypto.Crypto;
208 const completionShell = resolveCompletionShell(
209 yield* HostProcess.Platform,
210 yield* HostProcess.Environment,
211 );
212
213 /**
214 * Watches the setup terminal for the completion sentinel. Terminal output is
215 * a byte stream, so partial lines are buffered until a newline. The
216 * subscription is torn down once the sentinel, an exit, or a close arrives.
217 */
218 const observeTerminalCompletion = (input: {
219 readonly threadId: string;
220 readonly terminalId: string;
221 /** Per-run sentinel, so only this run's wrapper can settle completion. */
222 readonly sentinel: string;
223 readonly sentinelPattern: RegExp;
224 /** The shell echoes typed input; lines ending with these are the wrapper, not output. */
225 readonly echoedWrapperLines: ReadonlyArray<string>;
226 readonly onOutputLine: ((line: string) => Effect.Effect<void>) | undefined;
227 }) =>
228 Effect.gen(function* () {
229 const startedAtMs = yield* Clock.currentTimeMillis;
230 const done = yield* Deferred.make<ProjectSetupScriptCompletion>();
231 // The shell redraws its prompt just after the sentinel. Closing before
232 // that would read the redraw as new activity and keep an idle shell.
233 const promptReturned = yield* Deferred.make<void>();
234 let lineBuffer = "";
235 let settled = false;
236
237 const settle = (exitCode: number | null) =>
238 Effect.suspend(() => {
239 if (settled) return Effect.void;
240 settled = true;
241 return Clock.currentTimeMillis.pipe(
242 Effect.flatMap((nowMs) =>
243 Deferred.succeed(done, { exitCode, durationMs: nowMs - startedAtMs }),
244 ),
245 Effect.asVoid,
246 );
247 });
248
249 const handleLine = (rawLine: string) =>
250 Effect.suspend(() => {
251 const sentinel = input.sentinelPattern.exec(rawLine);
252 if (sentinel) {
253 const parsed = Number(sentinel[1]);
254 return settle(Number.isFinite(parsed) ? parsed : null);
255 }
256 const cleaned = stripTerminalControl(rawLine).trimEnd();
257 if (
258 cleaned.length === 0 ||
259 cleaned.includes(input.sentinel) ||
260 input.echoedWrapperLines.some((echoed) => cleaned.endsWith(echoed)) ||
261 input.onOutputLine === undefined
262 ) {
263 return Effect.void;
264 }
265 return input.onOutputLine(cleaned.slice(0, OUTPUT_LINE_MAX_LENGTH));
266 });
267
268 const unsubscribe = yield* terminalManager.subscribe((event) => {
269 if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
270 return Effect.void;
271 }
272 if (event.type === "output") {
273 lineBuffer += event.data;
274 // A bare carriage return is how installers redraw a progress line in
275 // place; each redraw becomes a short line of its own instead of
276 // being glued into one long one. The wrapper echo is filtered per
277 // segment too, which is why `echoedWrapperLines` is split on the
278 // same `\r`: a line editor repainting the typed command yields the
279 // same segments.
280 const lines = lineBuffer.split(/\r\n|\r|\n/);
281 lineBuffer = lines.pop() ?? "";
282 // A script that never prints a newline must not grow this forever.
283 // The sentinel is always on its own line, so keeping the tail is safe.
284 if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) {
285 lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH);
286 }
287 return Effect.forEach(lines, handleLine, { discard: true }).pipe(
288 // A prompt has no newline, so it is what remains once the sentinel is in.
289 Effect.andThen(
290 Effect.suspend(() =>
291 settled && lineBuffer.length > 0
292 ? Deferred.succeed(promptReturned, undefined).pipe(Effect.asVoid)
293 : Effect.void,
294 ),
295 ),
296 );
297 }
298 if (event.type === "exited" || event.type === "closed") {
299 return settle(null).pipe(Effect.andThen(Deferred.succeed(promptReturned, undefined)));
300 }
301 return Effect.void;
302 });
303
304 const completion = Deferred.await(done).pipe(
305 // A shell with an empty prompt never prints one; do not wait forever.
306 Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))),
307 Effect.ensuring(Effect.sync(() => unsubscribe())),
308 );
309 return { completion, unsubscribe };
310 });
311
312 const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(
313 "ProjectSetupScriptRunner.runForThread",
314 )(function* (input) {
315 const errorContext = {
316 threadId: input.threadId,
317 worktreePath: input.worktreePath,
318 ...(input.projectId === undefined ? {} : { projectId: input.projectId }),
319 ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }),
320 };
321 const suppliedProject = input.project;
322 const projectById =
323 suppliedProject ??
324 (input.projectId
325 ? yield* projects.getById(ProjectId.make(input.projectId)).pipe(
326 Effect.map(Option.getOrUndefined),
327 Effect.mapError(
328 (cause) =>
329 new ProjectSetupScriptOperationError({
330 ...errorContext,
331 operation: "resolveProject",
332 cause,
333 }),
334 ),
335 )
336 : null);
337 const project =
338 suppliedProject ??
339 projectById ??
340 (input.projectCwd
341 ? yield* projects.getByWorkspaceRoot(input.projectCwd).pipe(
342 Effect.map(Option.getOrUndefined),
343 Effect.mapError(
344 (cause) =>
345 new ProjectSetupScriptOperationError({
346 ...errorContext,
347 operation: "resolveProject",
348 cause,
349 }),
350 ),
351 )
352 : null);
353
354 if (!project) {
355 return yield* new ProjectSetupScriptProjectNotFoundError(errorContext);
356 }
357
358 const settings = yield* serverSettings.getSettings.pipe(
359 Effect.mapError(
360 (cause) =>
361 new ProjectSetupScriptOperationError({
362 ...errorContext,
363 operation: "readSettings",
364 cause,
365 }),
366 ),
367 );
368 const trigger = input.trigger ?? "setup";
369 const scripts = resolveProjectScripts(settings, project);
370 const script =
371 trigger === "settle" ? settleProjectScript(scripts) : setupProjectScript(scripts);
372 if (!script) {
373 return {
374 status: "no-script",
375 } as const;
376 }
377
378 // A thread settles again after it is resumed, and an earlier settle shell
379 // may still be busy; typing into it would feed its foreground program.
380 const terminalId =
381 input.preferredTerminalId ??
382 (trigger === "settle"
383 ? `settle-${script.id}-${(yield* crypto.randomUUIDv4().pipe(Effect.orDie)).slice(0, 8)}`
384 : `setup-${script.id}`);
385 const cwd = input.worktreePath;
386 const env = {
387 ...projectScriptRuntimeEnv({
388 project: { cwd: project.workspaceRoot },
389 worktreePath: input.worktreePath,
390 }),
391 // Setup can run before a client attaches. Truecolor probes in tools such
392 // as Vite+ wait for terminal replies that nobody can send at that point.
393 // Keep TERM's 256-color support without advertising truecolor here.
394 COLORTERM: "",
395 };
396 const observe = input.observeCompletion;
397 const completionToken = observe
398 ? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).replaceAll("-", "")
399 : null;
400 const commandLine =
401 observe && completionToken
402 ? wrapCommandForCompletion(
403 script.command,
404 completionShell,
405 completionSentinel(completionToken),
406 )
407 : script.command;
408
409 yield* terminalManager
410 .open({
411 threadId: input.threadId,
412 terminalId,
413 cwd,
414 worktreePath: input.worktreePath,
415 // Setup may run before a terminal client attaches to answer color probes.
416 env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" },
417 })
418 .pipe(
419 Effect.mapError(
420 (cause) =>
421 new ProjectSetupScriptOperationError({
422 ...errorContext,
423 operation: "openTerminal",
424 cause,
425 }),
426 ),
427 );
428 // Subscribe before writing so the sentinel cannot race past the listener.
429 const observed =
430 observe && completionToken
431 ? yield* observeTerminalCompletion({
432 threadId: input.threadId,
433 terminalId,
434 sentinel: completionSentinel(completionToken),
435 sentinelPattern: completionSentinelPattern(completionToken),
436 echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0),
437 onOutputLine: observe.onOutputLine,
438 })
439 : undefined;
440
441 yield* terminalManager
442 .write({
443 threadId: input.threadId,
444 terminalId,
445 data: `${commandLine}\r`,
446 })
447 .pipe(
448 Effect.mapError(
449 (cause) =>
450 new ProjectSetupScriptOperationError({
451 ...errorContext,
452 operation: "writeCommand",
453 cause,
454 }),
455 ),
456 // Nothing will ever settle the completion if the command never ran.
457 Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())),
458 );
459
460 // A clean run leaves only an idle prompt behind; its output stays in the
461 // terminal history. A failed run keeps its shell open for a look.
462 const completion = observed?.completion.pipe(
463 Effect.tap(({ exitCode }) =>
464 exitCode === 0
465 ? terminalManager.closeIdle({ threadId: input.threadId, terminalId })
466 : Effect.void,
467 ),
468 );
469
470 return {
471 status: "started",
472 scriptId: script.id,
473 scriptName: script.name,
474 scriptCommand: script.command,
475 terminalId,
476 cwd,
477 async: script.async !== false,
478 ...(completion ? { completion } : {}),
479 } as const;
480 });
481
482 return ProjectSetupScriptRunner.of({ runForThread });
483});
484
485export const layer = Layer.effect(ProjectSetupScriptRunner, make);