1import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts";
2import {
3 projectScriptRuntimeEnv,
4 projectScriptTerminalInput,
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 SecureRandom from "effect/crypto/SecureRandom";
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 ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
20import * as ServerSettings from "../serverSettings.ts";
21import * as TerminalManager from "../terminal/Manager.ts";
22import * as ProjectService from "./ProjectService.ts";
23
24export interface ProjectSetupScriptRunnerResultNoScript {
25 readonly status: "no-script";
26}
27
28export interface ProjectSetupScriptRunnerResultStarted {
29 readonly status: "started";
30 readonly scriptId: string;
31 readonly scriptName: string;
32 readonly scriptCommand: string;
33 readonly terminalId: string;
34 readonly cwd: string;
35 readonly async: boolean;
36 /** Null exit code means the terminal exited or closed before the sentinel. */
37 readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>;
38}
39
40export interface ProjectSetupScriptCompletion {
41 readonly exitCode: number | null;
42 readonly durationMs: number;
43}
44
45export interface ProjectSetupScriptOutputLine {
46 readonly line: string;
47}
48
49/**
50 * A thread a link below full access started does not run this project's setup
51 * script: the script is the user's own code, and such a link only grants T3's
52 * tools (see `mcp/linkOrigin.ts`).
53 */
54export interface ProjectSetupScriptRunnerResultSkippedForLink {
55 readonly status: "skipped-for-link";
56}
57
58export type ProjectSetupScriptRunnerResult =
59 | ProjectSetupScriptRunnerResultNoScript
60 | ProjectSetupScriptRunnerResultSkippedForLink
61 | ProjectSetupScriptRunnerResultStarted;
62
63export interface ProjectSetupScriptRunnerInput {
64 readonly threadId: string;
65 readonly projectId?: string;
66 readonly projectCwd?: string;
67 readonly worktreePath: string;
68 readonly trigger?: "setup" | "settle";
69 readonly project?: {
70 readonly id: ProjectId;
71 readonly workspaceRoot: string;
72 readonly scripts: ReadonlyArray<ProjectScript>;
73 };
74 readonly observeCompletion?: {
75 readonly onOutputLine?: (line: string) => Effect.Effect<void>;
76 };
77}
78
79export class ProjectSetupScriptOperationError extends Schema.TaggedError<ProjectSetupScriptOperationError>()(
80 "ProjectSetupScriptOperationError",
81 {
82 threadId: Schema.String,
83 projectId: Schema.optional(Schema.String),
84 projectCwd: Schema.optional(Schema.String),
85 worktreePath: Schema.String,
86 operation: Schema.Literals(["readThread", "resolveProject", "readSettings", "openTerminal"]),
87 cause: Schema.Defect(),
88 },
89) {
90 override get message(): string {
91 return `Project setup script operation '${this.operation}' failed for thread '${this.threadId}' in '${this.worktreePath}'.`;
92 }
93}
94
95export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedError<ProjectSetupScriptProjectNotFoundError>()(
96 "ProjectSetupScriptProjectNotFoundError",
97 {
98 threadId: Schema.String,
99 projectId: Schema.optional(Schema.String),
100 projectCwd: Schema.optional(Schema.String),
101 worktreePath: Schema.String,
102 },
103) {
104 override get message(): string {
105 return `Project was not found for setup script execution for thread '${this.threadId}' in '${this.worktreePath}'.`;
106 }
107}
108
109export class ProjectSetupScriptUnsupportedShellError extends Schema.TaggedError<ProjectSetupScriptUnsupportedShellError>()(
110 "ProjectSetupScriptUnsupportedShellError",
111 { shell: Schema.String },
112) {
113 override get message(): string {
114 return "Project script completion is not supported in cmd.exe. Install PowerShell and retry.";
115 }
116}
117
118export const ProjectSetupScriptRunnerError = Schema.Union([
119 ProjectSetupScriptOperationError,
120 ProjectSetupScriptProjectNotFoundError,
121 ProjectSetupScriptUnsupportedShellError,
122]);
123export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type;
124
125export class ProjectSetupScriptRunner extends Context.Service<
126 ProjectSetupScriptRunner,
127 {
128 readonly runForThread: (
129 input: ProjectSetupScriptRunnerInput,
130 ) => Effect.Effect<ProjectSetupScriptRunnerResult, ProjectSetupScriptRunnerError>;
131 }
132>()("t3/project/ProjectSetupScriptRunner") {}
133
134/** @public */
135// Random per-run tokens prevent script output from spoofing completion.
136const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__";
137const OUTPUT_LINE_MAX_LENGTH = 400;
138const PARTIAL_LINE_MAX_LENGTH = 4_096;
139
140function completionSentinel(token: string): string {
141 return `${COMPLETION_SENTINEL_PREFIX}_${token}:`;
142}
143
144function completionSentinelPattern(token: string): RegExp {
145 return new RegExp(`${COMPLETION_SENTINEL_PREFIX}_${token}:(-?\\d+)`);
146}
147
148function stripTerminalControl(text: string): string {
149 return text
150 .replace(
151 /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g,
152 "",
153 )
154 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
155}
156
157// Line editors (zsh, fish, nu, bash 5.1+) enable bracketed paste when they start reading a command.
158const BRACKETED_PASTE_ON = "\x1b[?2004h";
159const BRACKETED_PASTE_OFF = "\x1b[?2004l";
160// Longer than one subprocess poll plus its ps timeout, so a startup prompt is seen as busy first.
161const SHELL_QUIET_FALLBACK = "3 seconds";
162
163function lineEditorReading(text: string, previous: boolean): boolean {
164 const on = text.lastIndexOf(BRACKETED_PASTE_ON);
165 const off = text.lastIndexOf(BRACKETED_PASTE_OFF);
166 return on === off ? previous : on > off;
167}
168
169type CompletionShell = "posix" | "fish" | "nushell" | "powershell";
170
171function resolveCompletionShell(shell: string): CompletionShell | null {
172 const name = (shell.split(/[/\\]/).at(-1) ?? shell).toLowerCase().replace(/\.exe$/, "");
173 if (name === "cmd") return null;
174 if (name === "fish") return "fish";
175 if (name === "nu") return "nushell";
176 if (name === "pwsh" || name === "powershell") return "powershell";
177 return "posix";
178}
179
180// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin.
181function wrapCommandForCompletion(
182 command: string,
183 shell: CompletionShell,
184 sentinel: string,
185): string {
186 const body = command.replace(/\r?\n/g, "\r");
187 switch (shell) {
188 case "powershell":
189 return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`;
190 case "fish":
191 return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`;
192 case "nushell": {
193 const escaped = command.replace(/["\\\x00-\x1f]/g, (char) =>
194 char === '"' || char === "\\" ? `\\${char}` : `\\u{${char.charCodeAt(0).toString(16)}}`,
195 );
196 // Nu resolves imports before running cd; source a file rooted in the worktree.
197 return [
198 "$env.LAST_EXIT_CODE = 0",
199 "let __t3f = try { mktemp --tmpdir-path $env.T3CODE_WORKTREE_PATH --suffix .nu .t3-setup-XXXXXXXX } catch {|e| print -e $e.msg; null }",
200 `if $__t3f == null { $env.LAST_EXIT_CODE = 1 } else { try { [$"rm --permanent ($__t3f | to nuon)" "${escaped}"] | str join (char newline) | save --force --raw $__t3f; ^$nu.current-exe --login --commands $"cd $env.T3CODE_WORKTREE_PATH; source ($__t3f | to nuon)" } catch {|e| if $e != null and $e.exit_code? == null { print -e $e.msg }; if $env.LAST_EXIT_CODE == 0 { $env.LAST_EXIT_CODE = 1 } } }`,
201 "let __t3c = $env.LAST_EXIT_CODE",
202 "try { if $__t3f != null and ($__t3f | path exists) { rm --permanent $__t3f } } catch {|e| print -e $e.msg }",
203 `print $"\\n${sentinel}($__t3c)"`,
204 ].join("; ");
205 }
206 case "posix":
207 return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`;
208 }
209}
210
211/** @public */
212export const make = Effect.gen(function* () {
213 const projects = yield* ProjectService.ProjectService;
214 const threads = yield* ProjectionStore.ProjectionStoreV2;
215 const terminalManager = yield* TerminalManager.TerminalManager;
216 const serverSettings = yield* ServerSettings.ServerSettingsService;
217 const random = yield* SecureRandom.SecureRandom;
218 const serviceScope = yield* Effect.scope;
219
220 const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")(
221 function* (input: {
222 readonly threadId: string;
223 readonly terminalId: string;
224 readonly sentinel: string;
225 readonly sentinelPattern: RegExp;
226 /** The shell echoes typed input; lines ending with these are the wrapper, not output. */
227 readonly echoedWrapperLines: ReadonlyArray<string>;
228 readonly onOutputLine: ((line: string) => Effect.Effect<void>) | undefined;
229 }) {
230 const startedAtMs = yield* Clock.currentTimeMillis;
231 const done = yield* Deferred.make<ProjectSetupScriptCompletion>();
232 // Wait for the prompt redraw; closing earlier mistakes it for activity and preserves 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 // Bare CR redraws progress in place; split redraws instead of growing one line.
275 const lines = lineBuffer.split(/\r\n|\r|\n/);
276 lineBuffer = lines.pop() ?? "";
277 // Bound newline-free output; the sentinel has its own line, so retaining the tail is safe.
278 if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) {
279 lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH);
280 }
281 return Effect.forEach(lines, handleLine, { discard: true }).pipe(
282 Effect.andThen(
283 Effect.suspend(() =>
284 settled && lineBuffer.length > 0
285 ? Deferred.succeed(promptReturned, undefined).pipe(Effect.asVoid)
286 : Effect.void,
287 ),
288 ),
289 );
290 }
291 if (event.type === "exited" || event.type === "closed") {
292 return settle(null).pipe(Effect.andThen(Deferred.succeed(promptReturned, undefined)));
293 }
294 return Effect.void;
295 });
296
297 const completion = Deferred.await(done).pipe(
298 // A shell with an empty prompt never prints one; do not wait forever.
299 Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))),
300 Effect.ensuring(Effect.sync(() => unsubscribe())),
301 );
302 // A startup prompt waits for input on a line that has no newline yet.
303 const flushPartialLine = Effect.suspend(() => {
304 const partial = lineBuffer;
305 lineBuffer = "";
306 return handleLine(partial);
307 });
308 const abandon = settle(null).pipe(
309 Effect.andThen(Deferred.succeed(promptReturned, undefined)),
310 );
311 return { completion, unsubscribe, flushPartialLine, abandon };
312 },
313 );
314
315 // Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the
316 // shell, which loses the command. Subscribe before the terminal opens so no activity is missed.
317 const watchShellReadiness = Effect.fn("ProjectSetupScriptRunner.watchShellReadiness")(
318 function* (input: {
319 readonly threadId: string;
320 readonly terminalId: string;
321 readonly onStartupPrompt: Effect.Effect<void>;
322 }) {
323 const outcome = yield* Deferred.make<"ready" | "exited">();
324 let reading = false;
325 let carry = "";
326 let busy = false;
327 let activity = 0;
328 const unsubscribe = yield* terminalManager.subscribe((event) => {
329 if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
330 return Effect.void;
331 }
332 activity += 1;
333 // A new shell process starts idle; the manager resets its subprocess state without an event.
334 if (event.type === "started" || event.type === "restarted") {
335 busy = false;
336 reading = false;
337 carry = "";
338 }
339 if (event.type === "activity") busy = event.hasRunningSubprocess;
340 if (event.type === "output") {
341 const text = carry + event.data;
342 carry = text.slice(-(BRACKETED_PASTE_ON.length - 1));
343 reading = lineEditorReading(text, reading);
344 }
345 if (event.type === "exited" || event.type === "closed") {
346 return Deferred.succeed(outcome, "exited").pipe(Effect.asVoid);
347 }
348 return reading ? Deferred.succeed(outcome, "ready").pipe(Effect.asVoid) : Effect.void;
349 });
350 // A startup prompt runs as a child process, so a busy shell keeps waiting for the user.
351 const quiet = Effect.gen(function* () {
352 while (true) {
353 const seen = activity;
354 yield* Effect.sleep(SHELL_QUIET_FALLBACK);
355 if (activity !== seen) continue;
356 if (!busy) return "ready" as const;
357 yield* input.onStartupPrompt;
358 }
359 });
360 // Restored history can hold a previous shell's line editor state, so only live output counts.
361 const wait = Deferred.await(outcome).pipe(Effect.raceFirst(quiet));
362 return { wait, unsubscribe };
363 },
364 );
365
366 const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(
367 "ProjectSetupScriptRunner.runForThread",
368 )(function* (input) {
369 const errorContext = {
370 threadId: input.threadId,
371 worktreePath: input.worktreePath,
372 ...(input.projectId === undefined ? {} : { projectId: input.projectId }),
373 ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }),
374 };
375 // A thread whose origin cannot be read might be a link's: run nothing.
376 const thread = yield* threads.getThreadShell(ThreadId.make(input.threadId)).pipe(
377 Effect.mapError(
378 (cause) =>
379 new ProjectSetupScriptOperationError({
380 ...errorContext,
381 operation: "readThread",
382 cause,
383 }),
384 ),
385 );
386 if (thread?.linkOrigin !== undefined && linkOriginIsFenced(thread.linkOrigin))
387 return { status: "skipped-for-link" } as const;
388 const suppliedProject = input.project;
389 const projectById =
390 suppliedProject ??
391 (input.projectId
392 ? yield* projects.getById(ProjectId.make(input.projectId)).pipe(
393 Effect.map(Option.getOrUndefined),
394 Effect.mapError(
395 (cause) =>
396 new ProjectSetupScriptOperationError({
397 ...errorContext,
398 operation: "resolveProject",
399 cause,
400 }),
401 ),
402 )
403 : null);
404 const project =
405 suppliedProject ??
406 projectById ??
407 (input.projectCwd
408 ? yield* projects.getByWorkspaceRoot(input.projectCwd).pipe(
409 Effect.map(Option.getOrUndefined),
410 Effect.mapError(
411 (cause) =>
412 new ProjectSetupScriptOperationError({
413 ...errorContext,
414 operation: "resolveProject",
415 cause,
416 }),
417 ),
418 )
419 : null);
420
421 if (!project) {
422 return yield* new ProjectSetupScriptProjectNotFoundError(errorContext);
423 }
424
425 const settings = yield* serverSettings.getSettings.pipe(
426 Effect.mapError(
427 (cause) =>
428 new ProjectSetupScriptOperationError({
429 ...errorContext,
430 operation: "readSettings",
431 cause,
432 }),
433 ),
434 );
435 const trigger = input.trigger ?? "setup";
436 const scripts = resolveProjectScripts(settings, project);
437 const script =
438 trigger === "settle" ? settleProjectScript(scripts) : setupProjectScript(scripts);
439 if (!script) {
440 return {
441 status: "no-script",
442 } as const;
443 }
444
445 // Earlier settle shells may still run foreground programs; do not type resumed settles into them.
446 const terminalId =
447 trigger === "settle"
448 ? `settle-${script.id}-${(yield* random.uuidV4()).slice(0, 8)}`
449 : `setup-${script.id}`;
450 const cwd = input.worktreePath;
451 const env = {
452 ...projectScriptRuntimeEnv({
453 project: { cwd: project.workspaceRoot },
454 worktreePath: input.worktreePath,
455 }),
456 // Before client attachment, truecolor probes wait for terminal replies nobody can send.
457 COLORTERM: "",
458 };
459 const observe = input.observeCompletion;
460 const completionToken = observe ? (yield* random.uuidV4()).replaceAll("-", "") : null;
461 const echoedWrapperLines: string[] = [];
462 // Subscribe before opening so neither startup activity nor the sentinel can race the listeners.
463 const observed =
464 observe && completionToken
465 ? yield* observeTerminalCompletion({
466 threadId: input.threadId,
467 terminalId,
468 sentinel: completionSentinel(completionToken),
469 sentinelPattern: completionSentinelPattern(completionToken),
470 echoedWrapperLines,
471 onOutputLine: observe.onOutputLine,
472 })
473 : undefined;
474 const readiness = yield* watchShellReadiness({
475 threadId: input.threadId,
476 terminalId,
477 onStartupPrompt: observed?.flushPartialLine ?? Effect.void,
478 });
479 const { completionShell, commandLine } = yield* Effect.gen(function* () {
480 const shell = yield* terminalManager
481 .open({
482 threadId: input.threadId,
483 terminalId,
484 stopOnSettle: script.stopOnSettle ?? false,
485 cwd,
486 worktreePath: input.worktreePath,
487 env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" },
488 })
489 .pipe(
490 Effect.andThen(() =>
491 terminalManager
492 .getShell({ threadId: input.threadId, terminalId })
493 .pipe(Effect.catchTags({ TerminalNotRunningError: () => Effect.succeed(null) })),
494 ),
495 Effect.mapError(
496 (cause) =>
497 new ProjectSetupScriptOperationError({
498 ...errorContext,
499 operation: "openTerminal",
500 cause,
501 }),
502 ),
503 );
504 if (shell === null) {
505 yield* observed?.abandon ?? Effect.void;
506 return { completionShell: null, commandLine: null };
507 }
508 const completionShell = resolveCompletionShell(shell);
509 let commandLine = script.command;
510 if (observe && completionToken) {
511 if (completionShell === null) {
512 yield* terminalManager.closeIdle({ threadId: input.threadId, terminalId });
513 return yield* new ProjectSetupScriptUnsupportedShellError({ shell });
514 }
515 commandLine = wrapCommandForCompletion(
516 script.command,
517 completionShell,
518 completionSentinel(completionToken),
519 );
520 }
521 echoedWrapperLines.push(...commandLine.split("\r").filter((line) => line.length > 0));
522 return { completionShell, commandLine };
523 }).pipe(
524 Effect.onError(() =>
525 Effect.sync(() => {
526 observed?.unsubscribe();
527 readiness.unsubscribe();
528 }),
529 ),
530 );
531
532 // Return once the terminal exists so callers can show it while a startup prompt waits.
533 // PowerShell and cmd have no line-editor signal.
534 yield* (
535 completionShell === "powershell" || completionShell === null
536 ? Effect.succeed("ready" as const)
537 : readiness.wait
538 ).pipe(
539 Effect.flatMap((shell) =>
540 shell === "exited" || commandLine === null
541 ? Effect.void
542 : terminalManager.write({
543 threadId: input.threadId,
544 terminalId,
545 data: projectScriptTerminalInput(commandLine),
546 }),
547 ),
548 Effect.catchCause((cause) =>
549 Effect.logWarning("project script command was not written", {
550 ...errorContext,
551 terminalId,
552 cause,
553 }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)),
554 ),
555 Effect.ensuring(Effect.sync(() => readiness.unsubscribe())),
556 Effect.forkIn(serviceScope),
557 );
558
559 const completion = observed?.completion.pipe(
560 Effect.tap(({ exitCode }) =>
561 exitCode === 0
562 ? terminalManager.closeIdle({ threadId: input.threadId, terminalId })
563 : Effect.void,
564 ),
565 );
566
567 return {
568 status: "started",
569 scriptId: script.id,
570 scriptName: script.name,
571 scriptCommand: script.command,
572 terminalId,
573 cwd,
574 async: script.async !== false,
575 ...(completion ? { completion } : {}),
576 } as const;
577 });
578
579 return ProjectSetupScriptRunner.of({ runForThread });
580});
581
582export const layer = Layer.effect(ProjectSetupScriptRunner, make);