import { CommandId, type GitCommandError, MessageId, ProjectId, type ThreadMoveMcpInput, type ThreadMoveMcpResult, WorktreeMcpFailure, type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, type WorktreeMcpHandoffResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, type VcsListRefsInput, type VcsListRefsResult, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts"; export class WorktreeMcpService extends Context.Service< WorktreeMcpService, { readonly handoff: ( scope: McpInvocationScope, input: WorktreeMcpHandoffInput, ) => Effect.Effect; /** Moves the calling thread to the project rooted at a folder, registering one if needed. */ readonly moveToFolder: ( scope: McpInvocationScope, input: ThreadMoveMcpInput, ) => Effect.Effect; readonly status: ( scope: McpInvocationScope, ) => Effect.Effect; readonly listRefs: ( input: Omit & { readonly worktreePath: string | null; readonly workspaceRoot: string; }, ) => Effect.Effect; } >()("t3/mcp/WorktreeMcpService") {} function failure(code: WorktreeMcpFailure["code"], message: string): WorktreeMcpFailure { return new WorktreeMcpFailure({ code, message }); } function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "object" && error !== null && "message" in error) { return String((error as { message: unknown }).message); } return String(error); } const isUnfinishedSubagent = ({ status }: { readonly status: string }) => status === "pending" || status === "running" || status === "waiting"; const asOperationFailed = (prefix: string) => Effect.mapError((error: unknown) => failure("operation_failed", `${prefix}: ${errorMessage(error)}`), ); const make = Effect.gen(function* () { const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const threadManagement = yield* ThreadManagementService.ThreadManagementService; const projects = yield* ProjectService.ProjectService; const serverSettings = yield* ServerSettings.ServerSettingsService; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; // Serialize handoffs per thread to avoid creating two worktrees and orphaning one. const handoffThreadsInFlight = new Set(); const requireThreadScope = (scope: McpInvocationScope) => scope.thread === undefined ? Effect.fail( failure( "thread_credential_required", "Worktree handoff and status act as the calling T3 thread, so they need an agent running inside T3 Code.", ), ) : Effect.succeed(scope as McpThreadInvocationScope); const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("worktree") ? Effect.void : Effect.fail( failure("capability_denied", "This MCP credential does not grant worktree capabilities."), ); const loadThread = (scope: McpThreadInvocationScope) => threadManagement.getThreadRecords(scope.thread.threadId, []).pipe( Effect.mapError((error) => error._tag === "OrchestratorProjectionError" ? failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`) : failure( "operation_failed", `Unable to read thread ${scope.thread.threadId}: ${errorMessage(error)}`, ), ), Effect.filterOrFail( (projection) => projection.thread.deletedAt === null, () => failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`), ), ); const loadProject = (scope: McpThreadInvocationScope, projectId: ProjectId) => projects.getById(projectId).pipe( asOperationFailed(`Unable to read project ${projectId}`), Effect.flatMap( Option.match({ onNone: () => Effect.fail( failure( "project_not_found", `Project '${projectId}' was not found for thread '${scope.thread.threadId}'.`, ), ), onSome: Effect.succeed, }), ), ); const readDefaultStartFromOrigin = (projectId: ProjectId) => serverSettings.getSettings.pipe( Effect.map( (settings) => resolveProjectSettings(settings, projectId).settings.newWorktreesStartFromOrigin, ), asOperationFailed("Unable to read server settings"), ); const handoffIds = (scope: McpThreadInvocationScope) => random.uuidV4().pipe( Effect.map((uuid) => { const part = (kind: string, operation: string) => [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join( ":", ); return { commandId: CommandId.make(part("command", "worktree-handoff")), continuationCommandId: CommandId.make(part("command", "worktree-continuation")), continuationMessageId: MessageId.make(part("message", "worktree-continuation")), }; }), Effect.orDie, ); const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* ( scope: McpThreadInvocationScope, input: WorktreeMcpHandoffInput, ) { const alreadyInWorktree = (worktreePath: string) => failure( "already_in_worktree", `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`, ); const projection = yield* loadThread(scope); if (projection.thread.worktreePath !== null) { return yield* alreadyInWorktree(projection.thread.worktreePath); } if (projection.thread.archivedAt !== null) { return yield* failure( "invalid_request", `Thread '${scope.thread.threadId}' is archived and cannot be handed off to a worktree.`, ); } const project = yield* loadProject(scope, projection.thread.projectId); const projectCwd = project.workspaceRoot; const localStatus = yield* gitWorkflow .localStatus({ cwd: projectCwd }) .pipe(asOperationFailed("Unable to read git status")); if (!localStatus.isRepo) { return yield* failure( "invalid_request", `Project workspace '${projectCwd}' is not a git repository.`, ); } const localBranchNames = yield* gitWorkflow .listLocalBranchNames(projectCwd) .pipe(asOperationFailed("Unable to list branches")); if (localBranchNames.includes(input.branch)) { const existingRef = yield* gitWorkflow .listRefs({ cwd: projectCwd, query: input.branch, refKind: "local" }) .pipe( Effect.map((result) => result.refs.find((ref) => ref.name === input.branch && ref.isRemote !== true), ), Effect.orElseSucceed(() => undefined), ); const checkoutPath = existingRef?.worktreePath ?? null; return yield* failure( "invalid_request", `Branch '${input.branch}' already exists${ checkoutPath === null ? "" : ` and is checked out at '${checkoutPath}'` }. Choose a different branch name, or delete the existing branch${ checkoutPath === null ? "" : " and its worktree" } first.`, ); } let baseRef = input.baseRef; if (baseRef === undefined) { if (localStatus.refName === null) { return yield* failure( "invalid_request", "Could not determine the current branch of the project workspace (detached HEAD?). Pass baseRef explicitly.", ); } baseRef = localStatus.refName; } const startFromOrigin = input.startFromOrigin ?? (yield* readDefaultStartFromOrigin(project.id)); let worktreeBaseRef = baseRef; let baseRefName = baseRef; let startedFromOrigin = false; if (startFromOrigin) { const resolvedRemoteBase = yield* gitWorkflow .fetchWorktreeBaseCommit({ cwd: projectCwd, refName: baseRef }) .pipe(asOperationFailed(`Unable to fetch the remote base of '${baseRef}'`)); if (resolvedRemoteBase !== null) { worktreeBaseRef = resolvedRemoteBase.commitSha; baseRefName = `refs/remotes/${resolvedRemoteBase.remoteRefName}`; startedFromOrigin = true; } } const ids = yield* handoffIds(scope); // Mask interruption after worktree creation through setup; binding can detach and interrupt the caller. return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const worktree = yield* restore( gitWorkflow .createWorktree({ cwd: projectCwd, refName: worktreeBaseRef, newRefName: input.branch, baseRefName, path: null, }) .pipe(asOperationFailed("Unable to create the worktree")), ); const worktreePath = worktree.worktree.path; const reportFailed = (logMessage: string) => Effect.catchCause((cause: Cause.Cause) => { const detail = errorMessage(Cause.squash(cause)); return Effect.logWarning(logMessage, { threadId: scope.thread.threadId, worktreePath, detail, }).pipe(Effect.as({ status: "failed", detail } as const)); }); const removeCreatedWorktree = Effect.suspend(() => gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( Effect.andThen( Effect.suspend(() => gitWorkflow.deleteLocalBranch({ cwd: projectCwd, refName: worktree.worktree.refName, force: true, }), ), ), ), ).pipe(Effect.ignoreCause({ log: true })); const recheckAndBind = Effect.gen(function* () { // A concurrent UI binding may have landed during git work; do not orphan a second worktree. const recheck = yield* loadThread(scope); if (recheck.thread.worktreePath !== null) { return yield* alreadyInWorktree(recheck.thread.worktreePath); } if (recheck.thread.archivedAt !== null) { return yield* failure( "invalid_request", `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, ); } yield* threadManagement .dispatch({ type: "thread.metadata.update", commandId: ids.commandId, threadId: scope.thread.threadId, branch: worktree.worktree.refName, worktreePath, expectedWorktreePath: null, }) .pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause) : Effect.fail( failure( "operation_failed", `Unable to re-point the thread at the worktree: ${errorMessage(Cause.squash(cause))}`, ), ), ), ); }).pipe( // Do not remove on interruption alone; the thread binding may already have committed. Effect.onError((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree, ), ); // Queue continuation immediately after binding; detachment terminates the caller, so relaunch needs a durable message. const queueContinuation: Effect.Effect = Effect.suspend(() => input.continuationPrompt === undefined ? Effect.succeed({ status: "skipped" }) : threadManagement .sendToThread({ projectId: projection.thread.projectId, commandId: ids.continuationCommandId, threadId: scope.thread.threadId, messageId: ids.continuationMessageId, text: input.continuationPrompt, attachments: [], mode: "queue", createdBy: "agent", creationSource: "mcp", }) .pipe( Effect.map((sendResult): WorktreeMcpContinuationStatus => ({ status: "scheduled", delivery: sendResult.delivery, })), reportFailed("worktree handoff continuation failed to queue"), ), ); const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation)); yield* vcsStatusBroadcaster .refreshStatus(worktreePath) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach); let setupScript: WorktreeMcpSetupScriptStatus = { status: "skipped" }; if (input.runSetupScript ?? true) { setupScript = yield* setupScriptRunner .runForThread({ threadId: scope.thread.threadId, projectId: projection.thread.projectId, projectCwd, worktreePath, project: { id: project.id, workspaceRoot: project.workspaceRoot, scripts: project.scripts, }, }) .pipe( Effect.map((result): WorktreeMcpSetupScriptStatus => result.status === "started" ? { status: "started", scriptName: result.scriptName, terminalId: result.terminalId, } : result.status === "skipped-for-link" ? { status: "skipped" } : { status: "no-script" }, ), reportFailed("worktree handoff setup script failed"), ); } const result: WorktreeMcpHandoffResult = { worktreePath, branch: worktree.worktree.refName, baseRef, startedFromOrigin, setupScript, continuation, note: continuation.status === "scheduled" ? "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." : "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.", }; return result; }), ); }); const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")( function* (callerScope, input) { yield* requireCapability(callerScope); const scope = yield* requireThreadScope(callerScope); // Acquire and register cleanup atomically so cancellation cannot strand the guard until restart. return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { if (handoffThreadsInFlight.has(scope.thread.threadId)) { return Effect.fail( failure( "handoff_in_progress", `A worktree handoff is already in progress for thread '${scope.thread.threadId}'.`, ), ); } handoffThreadsInFlight.add(scope.thread.threadId); return restore(performHandoff(scope, input)).pipe( Effect.ensuring( Effect.sync(() => handoffThreadsInFlight.delete(scope.thread.threadId)), ), ); }), ); }, ); const moveToFolder: WorktreeMcpService["Service"]["moveToFolder"] = Effect.fn( "WorktreeMcpService.moveToFolder", )(function* (callerScope, input) { yield* requireCapability(callerScope); // Registering the destination project is an orchestration write. if (!callerScope.capabilities.has("orchestration")) { return yield* failure( "capability_denied", "This MCP credential cannot register the destination project.", ); } const scope = yield* requireThreadScope(callerScope); if (scope.thread.subagent) { return yield* failure("invalid_request", "A subagent stays with its parent thread."); } if (!path.isAbsolute(input.path)) { return yield* failure("invalid_request", `'${input.path}' is not an absolute path.`); } const workspaceRoot = path.resolve(input.path); const destination = yield* fileSystem.realPath(workspaceRoot).pipe( Effect.flatMap((resolved) => fileSystem.stat(resolved).pipe(Effect.map((info) => ({ resolved, info }))), ), Effect.mapError(() => failure("invalid_request", `Folder '${workspaceRoot}' does not exist.`), ), ); if (destination.info.type !== "Directory") { return yield* failure("invalid_request", `'${workspaceRoot}' is not a folder.`); } const records = yield* threadManagement .getThreadRecords(scope.thread.threadId, ["subagents"]) .pipe(asOperationFailed(`Unable to read thread ${scope.thread.threadId}`)); const { thread } = records; if (thread.deletedAt !== null) { return yield* failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`); } if (thread.archivedAt !== null) { return yield* failure("invalid_request", `Thread '${thread.id}' is archived.`); } if (records.subagents.some(isUnfinishedSubagent)) { return yield* failure( "invalid_request", "This thread still has running subagents. Wait for them to finish, then move it.", ); } const current = yield* loadProject(scope, thread.projectId); const currentFolder = yield* fileSystem .realPath(thread.worktreePath ?? current.workspaceRoot) .pipe(Effect.orElseSucceed(() => thread.worktreePath ?? current.workspaceRoot)); if (currentFolder === destination.resolved) { return yield* failure("invalid_request", `This thread already works in '${workspaceRoot}'.`); } const uuid = yield* random.uuidV4().pipe(Effect.orDie); const part = (kind: string, operation: string) => [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join(":"); const { projectId, projectCreated } = yield* projects .bootstrap({ commandId: CommandId.make(part("command", "thread-move-project")), projectId: ProjectId.make(uuid), title: path.basename(workspaceRoot) || workspaceRoot, workspaceRoot, }) .pipe( Effect.map(({ project, created }) => ({ projectId: project.id, projectCreated: created })), // bootstrap reads before locking; racing creates return the winner in a conflict. Effect.catchTags({ ProjectConflictError: (conflict) => Effect.succeed({ projectId: conflict.conflictingProjectId, projectCreated: false, }), }), asOperationFailed(`Unable to register a project for '${workspaceRoot}'`), ); const { movesWhenTurnEnds, continuationFailure } = yield* threadManagement .moveThreadToProject({ threadId: thread.id, projectId, commandId: CommandId.make(part("command", "thread-move")), afterMove: scheduledTasks .moveThreadTasks({ threadIds: [thread.id], projectId }) .pipe(Effect.ignoreCause({ log: true })), ...(input.continuationPrompt === undefined ? {} : { continuation: { commandId: CommandId.make(part("command", "thread-move-continuation")), messageId: MessageId.make(part("message", "thread-move-continuation")), text: input.continuationPrompt, }, }), }) .pipe(asOperationFailed("The thread did not move")); return { projectId, workspaceRoot, projectCreated, movesWhenTurnEnds, note: `The thread moves to ${workspaceRoot} ${ movesWhenTurnEnds ? "when this turn ends, so finish now and do not start more work here. A failed or stopped turn drops the move" : "now" }. ${ continuationFailure !== undefined ? `The continuation prompt was not queued (${continuationFailure}), so send it again.` : input.continuationPrompt === undefined ? "The conversation continues there when the thread receives its next message." : "The continuation prompt then starts the next turn there." } Files and any worktree in the previous folder stay where they are.`, }; }); const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")( function* (callerScope) { yield* requireCapability(callerScope); const scope = yield* requireThreadScope(callerScope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); const defaultStartFromOrigin = yield* readDefaultStartFromOrigin(project.id); const result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, worktreePath: projection.thread.worktreePath, branch: projection.thread.branch, projectWorkspaceRoot: project.workspaceRoot, defaultStartFromOrigin, }; return result; }, ); const listRefs: WorktreeMcpService["Service"]["listRefs"] = Effect.fn( "WorktreeMcpService.listRefs", )(function* ({ worktreePath, workspaceRoot, ...refs }) { // Storage cleanup removes a worktree but keeps its path on the thread. const cwd = worktreePath !== null && (yield* fileSystem.exists(worktreePath)) ? worktreePath : workspaceRoot; return yield* gitWorkflow.listRefs({ ...refs, cwd }); }); return WorktreeMcpService.of({ handoff, moveToFolder, status, listRefs }); }); export const layer: Layer.Layer< WorktreeMcpService, never, | SecureRandom.SecureRandom | FileSystem.FileSystem | Path.Path | ThreadManagementService.ThreadManagementService | ProjectService.ProjectService | ServerSettings.ServerSettingsService | ScheduledTaskService.ScheduledTaskService | GitWorkflowService.GitWorkflowService | ProjectSetupScriptRunner.ProjectSetupScriptRunner | VcsStatusBroadcaster.VcsStatusBroadcaster > = Layer.effect(WorktreeMcpService, make);