1import {
2 CommandId,
3 MessageId,
4 type ProjectId,
5 WorktreeMcpFailure,
6 type WorktreeMcpContinuationStatus,
7 type WorktreeMcpHandoffInput,
8 type WorktreeMcpHandoffResult,
9 type WorktreeMcpSetupScriptStatus,
10 type WorktreeMcpStatusResult,
11} from "@t3tools/contracts";
12import * as Cause from "effect/Cause";
13import * as Context from "effect/Context";
14import * as Crypto from "effect/Crypto";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19
20import * as GitWorkflowService from "../git/GitWorkflowService.ts";
21import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
22import * as ProjectService from "../project/ProjectService.ts";
23import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
24import * as ServerSettings from "../serverSettings.ts";
25import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
26import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts";
27
28export class WorktreeMcpService extends Context.Service<
29 WorktreeMcpService,
30 {
31 readonly handoff: (
32 scope: McpInvocationScope,
33 input: WorktreeMcpHandoffInput,
34 ) => Effect.Effect<WorktreeMcpHandoffResult, WorktreeMcpFailure>;
35 readonly status: (
36 scope: McpInvocationScope,
37 ) => Effect.Effect<WorktreeMcpStatusResult, WorktreeMcpFailure>;
38 }
39>()("t3/mcp/WorktreeMcpService") {}
40
41function failure(code: WorktreeMcpFailure["code"], message: string): WorktreeMcpFailure {
42 return new WorktreeMcpFailure({ code, message });
43}
44
45function errorMessage(error: unknown): string {
46 if (error instanceof Error) return error.message;
47 if (typeof error === "object" && error !== null && "message" in error) {
48 return String((error as { message: unknown }).message);
49 }
50 return String(error);
51}
52
53const asOperationFailed = (prefix: string) =>
54 Effect.mapError((error: unknown) =>
55 failure("operation_failed", `${prefix}: ${errorMessage(error)}`),
56 );
57
58const make = Effect.gen(function* () {
59 const crypto = yield* Crypto.Crypto;
60 const path = yield* Path.Path;
61 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
62 const projects = yield* ProjectService.ProjectService;
63 const serverSettings = yield* ServerSettings.ServerSettingsService;
64 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
65 const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
66 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
67
68 // Serializes handoffs per thread: two concurrent calls could otherwise both
69 // pass the worktreePath === null check and each create a worktree, leaving
70 // one untracked on disk.
71 const handoffThreadsInFlight = new Set<string>();
72
73 // Worktree tools act on the calling thread's own checkout binding.
74 const requireThreadScope = (scope: McpInvocationScope) =>
75 scope.thread === undefined
76 ? Effect.fail(
77 failure(
78 "thread_credential_required",
79 "Worktree handoff and status act as the calling T3 thread, so they need an agent running inside T3 Code.",
80 ),
81 )
82 : Effect.succeed(scope as McpThreadInvocationScope);
83
84 const requireCapability = (scope: McpInvocationScope) =>
85 scope.capabilities.has("worktree")
86 ? Effect.void
87 : Effect.fail(
88 failure("capability_denied", "This MCP credential does not grant worktree capabilities."),
89 );
90
91 const loadThread = (scope: McpThreadInvocationScope) =>
92 threadManagement.getThreadRecords(scope.thread.threadId, []).pipe(
93 Effect.mapError((error) =>
94 error._tag === "OrchestratorProjectionError"
95 ? failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`)
96 : failure(
97 "operation_failed",
98 `Unable to read thread ${scope.thread.threadId}: ${errorMessage(error)}`,
99 ),
100 ),
101 Effect.filterOrFail(
102 (projection) => projection.thread.deletedAt === null,
103 () => failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`),
104 ),
105 );
106
107 const loadProject = (scope: McpThreadInvocationScope, projectId: ProjectId) =>
108 projects.getById(projectId).pipe(
109 asOperationFailed(`Unable to read project ${projectId}`),
110 Effect.flatMap(
111 Option.match({
112 onNone: () =>
113 Effect.fail(
114 failure(
115 "project_not_found",
116 `Project '${projectId}' was not found for thread '${scope.thread.threadId}'.`,
117 ),
118 ),
119 onSome: Effect.succeed,
120 }),
121 ),
122 );
123
124 const readDefaultStartFromOrigin = serverSettings.getSettings.pipe(
125 Effect.map((settings) => settings.newWorktreesStartFromOrigin),
126 asOperationFailed("Unable to read server settings"),
127 );
128
129 const handoffIds = (scope: McpThreadInvocationScope) =>
130 crypto.randomUUIDv4().pipe(
131 Effect.map((uuid) => {
132 const part = (kind: string, operation: string) =>
133 [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join(
134 ":",
135 );
136 return {
137 commandId: CommandId.make(part("command", "worktree-handoff")),
138 continuationCommandId: CommandId.make(part("command", "worktree-continuation")),
139 continuationMessageId: MessageId.make(part("message", "worktree-continuation")),
140 };
141 }),
142 Effect.orDie,
143 );
144
145 const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* (
146 scope: McpThreadInvocationScope,
147 input: WorktreeMcpHandoffInput,
148 ) {
149 const alreadyInWorktree = (worktreePath: string) =>
150 failure(
151 "already_in_worktree",
152 `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`,
153 );
154
155 const projection = yield* loadThread(scope);
156 if (projection.thread.worktreePath !== null) {
157 return yield* alreadyInWorktree(projection.thread.worktreePath);
158 }
159 // An archived thread would accept the binding but refuse the continuation
160 // message (and any other follow-up), so reject the handoff outright.
161 if (projection.thread.archivedAt !== null) {
162 return yield* failure(
163 "invalid_request",
164 `Thread '${scope.thread.threadId}' is archived and cannot be handed off to a worktree.`,
165 );
166 }
167
168 const project = yield* loadProject(scope, projection.thread.projectId);
169 const projectCwd = project.workspaceRoot;
170
171 if (input.path !== undefined && !path.isAbsolute(input.path)) {
172 return yield* failure(
173 "invalid_request",
174 `path must be an absolute filesystem path, got '${input.path}'. A relative path would be created relative to the project workspace but stored verbatim as the thread's worktree binding.`,
175 );
176 }
177
178 // The repo check runs regardless of whether baseRef was supplied, so a
179 // non-repository workspace fails with an actionable error instead of an
180 // opaque git failure further down.
181 const localStatus = yield* gitWorkflow
182 .localStatus({ cwd: projectCwd })
183 .pipe(asOperationFailed("Unable to read git status"));
184 if (!localStatus.isRepo) {
185 return yield* failure(
186 "invalid_request",
187 `Project workspace '${projectCwd}' is not a git repository.`,
188 );
189 }
190
191 // Fail fast with an actionable message when the branch already exists:
192 // the git driver deliberately keeps stderr out of its errors, so letting
193 // `git worktree add` fail would surface only an opaque failure. The
194 // existence check uses the complete local branch list (exact match); the
195 // paginated substring search only enriches the message with the checkout
196 // location when available.
197 const localBranchNames = yield* gitWorkflow
198 .listLocalBranchNames(projectCwd)
199 .pipe(asOperationFailed("Unable to list branches"));
200 if (localBranchNames.includes(input.branch)) {
201 const existingRef = yield* gitWorkflow
202 .listRefs({ cwd: projectCwd, query: input.branch, refKind: "local" })
203 .pipe(
204 Effect.map((result) =>
205 result.refs.find((ref) => ref.name === input.branch && ref.isRemote !== true),
206 ),
207 Effect.orElseSucceed(() => undefined),
208 );
209 const checkoutPath = existingRef?.worktreePath ?? null;
210 return yield* failure(
211 "invalid_request",
212 `Branch '${input.branch}' already exists${
213 checkoutPath === null ? "" : ` and is checked out at '${checkoutPath}'`
214 }. Choose a different branch name, or delete the existing branch${
215 checkoutPath === null ? "" : " and its worktree"
216 } first.`,
217 );
218 }
219
220 let baseRef = input.baseRef;
221 if (baseRef === undefined) {
222 if (localStatus.refName === null) {
223 return yield* failure(
224 "invalid_request",
225 "Could not determine the current branch of the project workspace (detached HEAD?). Pass baseRef explicitly.",
226 );
227 }
228 baseRef = localStatus.refName;
229 }
230
231 const startFromOrigin = input.startFromOrigin ?? (yield* readDefaultStartFromOrigin);
232
233 let worktreeBaseRef = baseRef;
234 if (startFromOrigin) {
235 yield* gitWorkflow
236 .fetchRemote({ cwd: projectCwd, remoteName: "origin" })
237 .pipe(asOperationFailed("Unable to fetch origin"));
238 const resolvedRemoteBase = yield* gitWorkflow
239 .resolveRemoteTrackingCommit({
240 cwd: projectCwd,
241 refName: baseRef,
242 fallbackRemoteName: "origin",
243 })
244 .pipe(asOperationFailed(`Unable to resolve the remote-tracking commit of '${baseRef}'`));
245 worktreeBaseRef = resolvedRemoteBase.commitSha;
246 }
247
248 const ids = yield* handoffIds(scope);
249
250 // uninterruptibleMask: only the potentially slow worktree creation itself
251 // stays interruptible (restore). From the moment it succeeds, through the
252 // binding, continuation queue, setup script launch, and result
253 // construction, there is no interruptible gap: a client cancel can
254 // therefore neither orphan the created worktree before the rollback is
255 // armed, nor skip setting up a worktree the thread was just bound to
256 // (once the binding commits, the scheduled session detach can sever this
257 // request's connection and interrupt the fiber).
258 return yield* Effect.uninterruptibleMask((restore) =>
259 Effect.gen(function* () {
260 const worktree = yield* restore(
261 gitWorkflow
262 .createWorktree({
263 cwd: projectCwd,
264 refName: worktreeBaseRef,
265 newRefName: input.branch,
266 baseRefName: baseRef,
267 path: input.path ?? null,
268 })
269 .pipe(asOperationFailed("Unable to create the worktree")),
270 );
271 const worktreePath = worktree.worktree.path;
272
273 // Shared shape for "the handoff already succeeded, so report the failure
274 // in the result instead of failing the call" (continuation, setup script).
275 const reportFailed = (logMessage: string) =>
276 Effect.catchCause((cause: Cause.Cause<unknown>) => {
277 const detail = errorMessage(Cause.squash(cause));
278 return Effect.logWarning(logMessage, {
279 threadId: scope.thread.threadId,
280 worktreePath,
281 detail,
282 }).pipe(Effect.as({ status: "failed", detail } as const));
283 });
284
285 // suspend: build the rollback only if cleanup actually runs. Removing
286 // the worktree must succeed before deleting its freshly created branch;
287 // otherwise the branch may still be checked out there.
288 const removeCreatedWorktree = Effect.suspend(() =>
289 gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe(
290 Effect.andThen(
291 Effect.suspend(() =>
292 gitWorkflow.deleteLocalBranch({
293 cwd: projectCwd,
294 refName: worktree.worktree.refName,
295 force: true,
296 }),
297 ),
298 ),
299 ),
300 ).pipe(Effect.ignoreCause({ log: true }));
301
302 const recheckAndBind = Effect.gen(function* () {
303 // The projection was read before the potentially slow git work
304 // above; a concurrent binding (for example from the UI) could have
305 // attached the thread in the meantime. Re-check before committing so
306 // the race cannot leave a second, untracked worktree.
307 const recheck = yield* loadThread(scope);
308 if (recheck.thread.worktreePath !== null) {
309 return yield* alreadyInWorktree(recheck.thread.worktreePath);
310 }
311 // Mirror the up-front archived check: the thread may have been
312 // archived during the slow git work, and an archived thread must
313 // not be bound to a fresh worktree it can never use.
314 if (recheck.thread.archivedAt !== null) {
315 return yield* failure(
316 "invalid_request",
317 `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`,
318 );
319 }
320 yield* threadManagement
321 .dispatch({
322 type: "thread.metadata.update",
323 commandId: ids.commandId,
324 threadId: scope.thread.threadId,
325 branch: worktree.worktree.refName,
326 worktreePath,
327 expectedWorktreePath: null,
328 })
329 .pipe(
330 Effect.catchCause((cause) =>
331 // Interrupt-only causes propagate unchanged: whether the
332 // dispatch committed is unknown, so neither a typed failure
333 // nor a rollback would be correct. Failures and defects
334 // (including mixed causes) map to a typed operation_failed.
335 Cause.hasInterruptsOnly(cause)
336 ? Effect.failCause(cause as Cause.Cause<never>)
337 : Effect.fail(
338 failure(
339 "operation_failed",
340 `Unable to re-point the thread at the worktree: ${errorMessage(Cause.squash(cause))}`,
341 ),
342 ),
343 ),
344 );
345 }).pipe(
346 // onError: the worktree was already created, so any failure between
347 // here and the committed binding (recheck read, recheck race,
348 // dispatch typed failure or defect) must remove it again so a failed
349 // handoff leaves nothing behind on disk. Interrupt-only causes skip
350 // the removal: the binding may have committed, and force-deleting a
351 // worktree the thread now points at would be worse than leaking one.
352 Effect.onError((cause) =>
353 Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree,
354 ),
355 );
356
357 // Queue the continuation right after the binding commits: the detach
358 // that the metadata update schedules will terminate the calling
359 // session, and a durably queued message is what guarantees the thread
360 // re-launches inside the worktree. When the dying run reaches a
361 // terminal state the orchestrator promotes the queued run, which
362 // derives its cwd from the updated projection.
363 // suspend: build the send effect only when the binding has succeeded,
364 // so a failed dispatch never even constructs the continuation call.
365 const queueContinuation: Effect.Effect<WorktreeMcpContinuationStatus, WorktreeMcpFailure> =
366 Effect.suspend(() =>
367 input.continuationPrompt === undefined
368 ? Effect.succeed<WorktreeMcpContinuationStatus>({ status: "skipped" })
369 : threadManagement
370 .sendToThread({
371 projectId: projection.thread.projectId,
372 commandId: ids.continuationCommandId,
373 threadId: scope.thread.threadId,
374 messageId: ids.continuationMessageId,
375 text: input.continuationPrompt,
376 attachments: [],
377 mode: "queue",
378 createdBy: "agent",
379 creationSource: "mcp",
380 })
381 .pipe(
382 Effect.map((sendResult): WorktreeMcpContinuationStatus => ({
383 status: "scheduled",
384 delivery: sendResult.delivery,
385 })),
386 // catchCause via reportFailed: the binding is already recorded,
387 // so a failed continuation must be reported, not fail the handoff.
388 reportFailed("worktree handoff continuation failed to queue"),
389 ),
390 );
391
392 const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation));
393
394 yield* vcsStatusBroadcaster
395 .refreshStatus(worktreePath)
396 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach);
397
398 let setupScript: WorktreeMcpSetupScriptStatus = { status: "skipped" };
399 if (input.runSetupScript ?? true) {
400 setupScript = yield* setupScriptRunner
401 .runForThread({
402 threadId: scope.thread.threadId,
403 projectId: projection.thread.projectId,
404 projectCwd,
405 worktreePath,
406 project: {
407 id: project.id,
408 workspaceRoot: project.workspaceRoot,
409 scripts: project.scripts,
410 },
411 })
412 .pipe(
413 Effect.map((result): WorktreeMcpSetupScriptStatus =>
414 result.status === "started"
415 ? {
416 status: "started",
417 scriptName: result.scriptName,
418 terminalId: result.terminalId,
419 }
420 : { status: "no-script" },
421 ),
422 // catchCause via reportFailed: the thread is already re-pointed at the
423 // worktree, so even a defect in the setup runner must not fail the handoff.
424 reportFailed("worktree handoff setup script failed"),
425 );
426 }
427
428 const result: WorktreeMcpHandoffResult = {
429 worktreePath,
430 branch: worktree.worktree.refName,
431 baseRef,
432 startedFromOrigin: startFromOrigin,
433 setupScript,
434 continuation,
435 note:
436 continuation.status === "scheduled"
437 ? "Handoff recorded. Changing the workspace detaches this provider session, so the current turn ends shortly after this call; the queued continuation prompt then starts the next turn inside the worktree with the conversation preserved. The worktree is not removed automatically when the thread is deleted."
438 : "Handoff recorded. Changing the workspace detaches this provider session, so the current turn ends shortly after this call; the conversation continues inside the worktree when the thread receives its next message. Pass continuationPrompt to resume automatically. The worktree is not removed automatically when the thread is deleted.",
439 };
440 return result;
441 }),
442 );
443 });
444
445 const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")(
446 function* (callerScope, input) {
447 yield* requireCapability(callerScope);
448 const scope = yield* requireThreadScope(callerScope);
449 // uninterruptibleMask: the guard acquisition and the registration of the
450 // releasing finalizer happen with no interruptible gap in between. An
451 // interrupt landing between a bare add() and the start of an ensured
452 // effect would otherwise leak the guard entry and block every future
453 // handoff for this thread until restart.
454 return yield* Effect.uninterruptibleMask((restore) =>
455 Effect.suspend(() => {
456 if (handoffThreadsInFlight.has(scope.thread.threadId)) {
457 return Effect.fail(
458 failure(
459 "handoff_in_progress",
460 `A worktree handoff is already in progress for thread '${scope.thread.threadId}'.`,
461 ),
462 );
463 }
464 handoffThreadsInFlight.add(scope.thread.threadId);
465 return restore(performHandoff(scope, input)).pipe(
466 Effect.ensuring(
467 Effect.sync(() => handoffThreadsInFlight.delete(scope.thread.threadId)),
468 ),
469 );
470 }),
471 );
472 },
473 );
474
475 const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")(
476 function* (callerScope) {
477 yield* requireCapability(callerScope);
478 const scope = yield* requireThreadScope(callerScope);
479 const projection = yield* loadThread(scope);
480 const project = yield* loadProject(scope, projection.thread.projectId);
481
482 const defaultStartFromOrigin = yield* readDefaultStartFromOrigin;
483
484 const result: WorktreeMcpStatusResult = {
485 attached: projection.thread.worktreePath !== null,
486 worktreePath: projection.thread.worktreePath,
487 branch: projection.thread.branch,
488 projectWorkspaceRoot: project.workspaceRoot,
489 defaultStartFromOrigin,
490 };
491 return result;
492 },
493 );
494
495 return WorktreeMcpService.of({ handoff, status });
496});
497
498export const layer: Layer.Layer<
499 WorktreeMcpService,
500 never,
501 | Crypto.Crypto
502 | Path.Path
503 | ThreadManagementService.ThreadManagementService
504 | ProjectService.ProjectService
505 | ServerSettings.ServerSettingsService
506 | GitWorkflowService.GitWorkflowService
507 | ProjectSetupScriptRunner.ProjectSetupScriptRunner
508 | VcsStatusBroadcaster.VcsStatusBroadcaster
509> = Layer.effect(WorktreeMcpService, make);