| 1 | import { |
| 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"; |
| 12 | import * as Cause from "effect/Cause"; |
| 13 | import * as Context from "effect/Context"; |
| 14 | import * as Crypto from "effect/Crypto"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; |
| 18 | import * as Path from "effect/Path"; |
| 19 | |
| 20 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 21 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 22 | import * as ProjectService from "../project/ProjectService.ts"; |
| 23 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 24 | import * as ServerSettings from "../serverSettings.ts"; |
| 25 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; |
| 26 | import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts"; |
| 27 | |
| 28 | export 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 | |
| 41 | function failure(code: WorktreeMcpFailure["code"], message: string): WorktreeMcpFailure { |
| 42 | return new WorktreeMcpFailure({ code, message }); |
| 43 | } |
| 44 | |
| 45 | function 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 | |
| 53 | const asOperationFailed = (prefix: string) => |
| 54 | Effect.mapError((error: unknown) => |
| 55 | failure("operation_failed", `${prefix}: ${errorMessage(error)}`), |
| 56 | ); |
| 57 | |
| 58 | const 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 | |
| 498 | export 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); |