diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index ae544fc83f34..0e634fbf1a82 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -62,6 +62,9 @@ export class GitWorkflowService extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listWorktrees: ( + cwd: string, + ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; @@ -311,6 +314,10 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), + listWorktrees: (cwd) => + ensureGitCommand("GitWorkflowService.listWorktrees", cwd).pipe( + Effect.andThen(git.listWorktrees(cwd)), + ), createWorktree: (input) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 193087f2eb3c..17534520a585 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -3,23 +3,29 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, EnvironmentId, + GitManagerError, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, type Project, ProjectId, ProviderInstanceId, + RunId, ThreadId, WorktreeMcpHandoffInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as GitManager from "../git/GitManager.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import { OrchestratorDispatchError, @@ -33,6 +39,8 @@ import { import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import { layer as worktreeMcpServiceLayer, WorktreeMcpService } from "./WorktreeMcpService.ts"; @@ -74,6 +82,51 @@ const makeProjection = (overrides: ThreadFixture = {}): OrchestrationV2ThreadPro }, }) as OrchestrationV2ThreadProjection; +const shellFixture = ( + overrides: Partial, +): OrchestrationV2ThreadShell => { + const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Worktree test thread", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "test-model", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + activeProviderThreadId: null, + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + ...overrides, + }; +}; + const project: Project = { id: projectId, title: "Worktree test project", @@ -111,6 +164,51 @@ interface HarnessOptions { readonly removeWorktreeFails?: boolean; readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; + readonly refs?: ReadonlyArray<{ + readonly name: string; + readonly current: boolean; + readonly isDefault: boolean; + readonly worktreePath: string | null; + }>; + readonly worktrees?: ReadonlyArray<{ + readonly path: string; + readonly refName: string | null; + }>; + readonly worktreeInventories?: Readonly< + Record< + string, + { + readonly repositoryCommonDir: string; + readonly currentWorktreeRoot: string | null; + readonly worktrees: ReadonlyArray<{ + readonly path: string; + readonly refName: string | null; + }>; + } + > + >; + readonly projectWorktreeRoot?: string; + readonly projectWorkspaceRoot?: string; + readonly useRealNonRepositoryWorkflow?: boolean; + readonly workspaceStatuses?: Readonly< + Record + >; + readonly worktreeInventoryFailsFor?: ReadonlySet; + readonly localStatusFailsOnCall?: number; + readonly localStatusFailure?: "typed" | "defect" | "interrupt"; + readonly projectThreads?: ReadonlyArray<{ + readonly id: ThreadId; + readonly title: string; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly active?: boolean; + }>; + readonly archivedProjectThread?: { + readonly id: ThreadId; + readonly title: string; + readonly branch: string | null; + readonly worktreePath: string | null; + }; } const makeHarness = (options: HarnessOptions = {}) => { @@ -187,15 +285,72 @@ const makeHarness = (options: HarnessOptions = {}) => { return Effect.succeed({ delivery: "queued" } as ThreadManagementSendResult); } }); + const configuredProject = { + ...project, + workspaceRoot: options.projectWorkspaceRoot ?? project.workspaceRoot, + }; const getById = vi.fn((id: ProjectId) => options.projectReadFails ? (Effect.fail("simulated project read failure") as never) : Effect.succeed( id === projectId && options.projectMissing !== true - ? Option.some(project) + ? Option.some(configuredProject) : Option.none(), ), ); + const projectThreadShells = ( + options.projectThreads ?? [ + { + id: threadId, + title: "Worktree test thread", + branch: thread?.branch ?? null, + worktreePath: thread?.worktreePath ?? null, + }, + ] + ).map((item) => + shellFixture({ + id: item.id, + projectId, + title: item.title, + branch: item.branch, + worktreePath: item.worktreePath, + status: item.active === true ? "running" : "idle", + activeRunId: item.active === true ? RunId.make("run-active") : null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: item.id, + }, + }), + ); + const archivedThreadShells = + options.archivedProjectThread === undefined + ? [] + : [ + shellFixture({ + id: options.archivedProjectThread.id, + projectId, + title: options.archivedProjectThread.title, + branch: options.archivedProjectThread.branch, + worktreePath: options.archivedProjectThread.worktreePath, + activeRunId: null, + archivedAt: DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"), + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: options.archivedProjectThread.id, + }, + }), + ]; + const listProjectThreads = vi.fn(() => Effect.succeed(projectThreadShells)); + const getShellSnapshot = vi.fn(() => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: projectThreadShells, + archivedThreads: archivedThreadShells, + } as never), + ); const removeWorktree = vi.fn((_: unknown) => options.removeWorktreeFails ? (Effect.fail("simulated worktree removal failure") as never) @@ -229,10 +384,13 @@ const makeHarness = (options: HarnessOptions = {}) => { ), ), ); - const listRefs = vi.fn((input: { readonly query?: string | undefined }) => - Effect.succeed({ - refs: - options.existingBranchWorktreePath === undefined + const listRefs = vi.fn((input: { readonly query?: string | undefined }) => { + const refs = + options.refs !== undefined + ? options.refs.filter((ref) => + input.query === undefined ? true : ref.name.includes(input.query), + ) + : options.existingBranchWorktreePath === undefined ? [] : [ { @@ -241,23 +399,92 @@ const makeHarness = (options: HarnessOptions = {}) => { isDefault: false, worktreePath: options.existingBranchWorktreePath, }, - ], + ]; + return Effect.succeed({ + refs, isRepo: true, hasPrimaryRemote: true, nextCursor: null, - totalCount: options.existingBranchWorktreePath === undefined ? 0 : 1, - }), + totalCount: + options.refs?.length ?? (options.existingBranchWorktreePath === undefined ? 0 : 1), + }); + }); + const configuredWorktrees = + options.worktrees ?? + (options.refs ?? []).flatMap((ref) => + ref.worktreePath === null ? [] : [{ path: ref.worktreePath, refName: ref.name }], + ); + const projectWorktreeRoot = options.projectWorktreeRoot ?? workspaceRoot; + const listedWorktrees = configuredWorktrees.some( + (worktree) => worktree.path === projectWorktreeRoot, + ) + ? configuredWorktrees + : [ + { + path: projectWorktreeRoot, + refName: options.currentBranch === undefined ? "dev" : options.currentBranch, + }, + ...configuredWorktrees, + ]; + const listWorktrees = vi.fn((cwd: string) => + options.worktreeInventoryFailsFor?.has(cwd) === true + ? (Effect.fail("simulated worktree inventory failure") as never) + : Effect.succeed( + options.worktreeInventories?.[cwd] ?? { + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: + listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? + projectWorktreeRoot, + worktrees: listedWorktrees, + }, + ), ); - const localStatus = vi.fn((_: unknown) => - Effect.succeed({ - isRepo: options.notARepo !== true, + const workspaceStatuses = new Map( + Object.entries( + options.workspaceStatuses ?? { + [workspaceRoot]: { + branch: options.currentBranch === undefined ? "dev" : options.currentBranch, + }, + }, + ), + ); + let localStatusCallCount = 0; + const localStatus = vi.fn((input: { readonly cwd: string }) => { + localStatusCallCount += 1; + if (localStatusCallCount === 1 && options.localStatusFailure === "defect") { + return Effect.die(new Error("simulated local status defect")); + } + if (localStatusCallCount === 1 && options.localStatusFailure === "interrupt") { + return Effect.interrupt; + } + if (localStatusCallCount === 1 && options.localStatusFailure === "typed") { + return Effect.fail( + new GitManagerError({ + operation: "WorktreeMcpService.test.localStatus", + cwd: input.cwd, + detail: "simulated local status failure", + }), + ); + } + if (options.localStatusFailsOnCall === localStatusCallCount) { + return Effect.fail("simulated local status failure") as never; + } + const current = workspaceStatuses.get(input.cwd); + return Effect.succeed({ + isRepo: current?.isRepo ?? options.notARepo !== true, hasPrimaryRemote: true, isDefaultRef: false, - refName: options.currentBranch === undefined ? "dev" : options.currentBranch, - hasWorkingTreeChanges: false, + refName: + current === undefined + ? options.currentBranch === undefined + ? "dev" + : options.currentBranch + : current.branch, + hasWorkingTreeChanges: current?.dirty ?? false, workingTree: { files: [], insertions: 0, deletions: 0 }, - }), - ); + }); + }); + const invalidateLocalStatus = vi.fn((_: string) => Effect.void); const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub")); const runForThread = vi.fn((input: { readonly worktreePath: string }) => { switch (options.setupScript ?? "started") { @@ -300,12 +527,45 @@ const makeHarness = (options: HarnessOptions = {}) => { } as unknown as Path.Path), ), ); + const gitWorkflowLayer = options.useRealNonRepositoryWorkflow + ? GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: () => Effect.succeed(null), + resolve: () => Effect.fail("not a repository") as never, + }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(GitManager.GitManager)({ + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + resolvePullRequest: () => Effect.die("unexpected resolvePullRequest"), + preparePullRequestThread: () => Effect.die("unexpected preparePullRequestThread"), + }), + ), + ) + : Layer.mock(GitWorkflowService.GitWorkflowService)({ + listRefs, + listWorktrees, + listLocalBranchNames, + localStatus, + invalidateLocalStatus, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + removeWorktree, + deleteLocalBranch, + } satisfies Partial); const layer = serviceLayer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService)({ dispatch, + getShellSnapshot, getThreadProjection, + listProjectThreads, sendToThread, } satisfies Partial), Layer.mock(ProjectService.ProjectService)({ @@ -314,16 +574,7 @@ const makeHarness = (options: HarnessOptions = {}) => { ServerSettings.layerTest({ newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, }), - Layer.mock(GitWorkflowService.GitWorkflowService)({ - listRefs, - listLocalBranchNames, - localStatus, - fetchRemote, - resolveRemoteTrackingCommit, - createWorktree, - removeWorktree, - deleteLocalBranch, - } satisfies Partial), + gitWorkflowLayer, Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread, } satisfies Partial), @@ -346,6 +597,9 @@ const makeHarness = (options: HarnessOptions = {}) => { removeWorktree, deleteLocalBranch, localStatus, + listRefs, + listWorktrees, + listProjectThreads, runForThread, }; }; @@ -384,6 +638,15 @@ const runStatus = (harness: ReturnType) => return yield* service.status(harness.scope); }).pipe(Effect.provide(harness.layer)); +const runList = ( + harness: ReturnType, + input: Parameters[1] = {}, +) => + Effect.gen(function* () { + const service = yield* WorktreeMcpService; + return yield* service.listWorktrees(harness.scope, input); + }).pipe(Effect.provide(harness.layer)); + describe("t3_worktree_handoff", () => { it.effect("creates a worktree from the current branch and re-points the thread", () => { const harness = makeHarness(); @@ -968,34 +1231,108 @@ describe("t3_worktree_handoff", () => { }); describe("t3_worktree_status", () => { + it.effect("reports a plain project directory as not a repository", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const plainDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-status-non-repo-", + }); + const canonicalPlainDirectory = yield* fileSystem.realPath(plainDirectory); + const harness = makeHarness({ + projectWorkspaceRoot: canonicalPlainDirectory, + useRealNonRepositoryWorkflow: true, + }); + + const result = yield* runStatus(harness); + + expect(result).toMatchObject({ + attached: false, + projectWorkspaceRoot: canonicalPlainDirectory, + actualWorkspace: { + workspacePath: canonicalPlainDirectory, + branch: null, + isRepo: false, + hasWorkingTreeChanges: false, + }, + agreement: "not_repository", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("reports an unattached thread", () => { const harness = makeHarness({ newWorktreesStartFromOrigin: true }); return Effect.gen(function* () { const result = yield* runStatus(harness); - expect(result).toEqual({ + expect(result).toMatchObject({ attached: false, worktreePath: null, branch: null, projectWorkspaceRoot: workspaceRoot, defaultStartFromOrigin: true, + recordedWorkspace: { branch: null, worktreePath: null }, + actualWorkspace: { + workspacePath: workspaceRoot, + branch: "dev", + isRepo: true, + hasWorkingTreeChanges: false, + }, + agreement: "branch_mismatch", }); }); }); it.effect("reports an attached thread's worktree and branch", () => { + const worktreePath = "/worktrees/project/existing"; const harness = makeHarness({ thread: { - worktreePath: "/worktrees/project/existing", + worktreePath, branch: "feature/existing", }, + refs: [ + { + name: "feature/existing", + current: true, + isDefault: false, + worktreePath, + }, + ], + workspaceStatuses: { [worktreePath]: { branch: "feature/existing" } }, }); return Effect.gen(function* () { const result = yield* runStatus(harness); expect(result).toMatchObject({ attached: true, - worktreePath: "/worktrees/project/existing", + worktreePath, branch: "feature/existing", defaultStartFromOrigin: false, + actualWorkspace: { workspacePath: worktreePath, branch: "feature/existing", isRepo: true }, + agreement: "in_sync", + }); + }); + }); + + it.effect("reports a missing saved worktree even when inventory discovery fails", () => { + const missingPath = "/worktrees/project/deleted"; + const harness = makeHarness({ + thread: { worktreePath: missingPath, branch: "feature/deleted" }, + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [missingPath]: { branch: null, isRepo: false }, + }, + worktreeInventoryFailsFor: new Set([missingPath]), + }); + return Effect.gen(function* () { + const result = yield* runStatus(harness); + expect(result).toMatchObject({ + attached: true, + worktreePath: missingPath, + branch: "feature/deleted", + actualWorkspace: { + workspacePath: missingPath, + branch: null, + isRepo: false, + }, + agreement: "workspace_missing", }); }); }); @@ -1025,6 +1362,442 @@ describe("t3_worktree_status", () => { }); }); +describe("t3_worktree_list", () => { + it.effect("reports actual checkout state and durable thread bindings", () => { + const worktreePath = "/worktrees/project/feature-list"; + const otherThreadId = ThreadId.make("thread-worktree-other"); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + { name: "dev", current: true, isDefault: true, worktreePath: workspaceRoot }, + { + name: "feature/list", + current: false, + isDefault: false, + worktreePath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [worktreePath]: { branch: "feature/list", dirty: true }, + }, + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: otherThreadId, + title: "Other thread", + branch: "feature/list", + worktreePath, + active: true, + }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness); + expect(result.projectWorkspaceRoot).toBe(workspaceRoot); + expect(result.repositoryCommonDir).toBe("/repo/.git"); + expect(result.projectWorktreeRoot).toBe(workspaceRoot); + expect(result.nextCursor).toBeNull(); + expect(result.total).toBe(2); + expect(result.worktrees).toEqual([ + { + path: workspaceRoot, + branch: "dev", + actualBranch: "dev", + isRepo: true, + isProjectRoot: true, + hasWorkingTreeChanges: false, + availability: "available", + statusError: null, + bindings: [ + { + threadId, + title: "Caller", + status: "idle", + recordedBranch: "dev", + recordedWorktreePath: null, + active: false, + callingThread: true, + }, + ], + bindingCount: 1, + }, + { + path: worktreePath, + branch: "feature/list", + actualBranch: "feature/list", + isRepo: true, + isProjectRoot: false, + hasWorkingTreeChanges: true, + availability: "available", + statusError: null, + bindings: [ + { + threadId: otherThreadId, + title: "Other thread", + status: "running", + recordedBranch: "feature/list", + recordedWorktreePath: worktreePath, + active: true, + callingThread: false, + }, + ], + bindingCount: 1, + }, + ]); + }); + }); + + it.effect("includes detached worktrees without inventing a branch label", () => { + const detachedPath = "/worktrees/project/detached"; + const harness = makeHarness({ + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: detachedPath, refName: null }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [detachedPath]: { branch: null }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness); + expect(result.worktrees).toContainEqual({ + path: detachedPath, + branch: null, + actualBranch: null, + isRepo: true, + isProjectRoot: false, + hasWorkingTreeChanges: false, + availability: "available", + statusError: null, + bindings: [], + bindingCount: 0, + }); + }); + }); + + it.effect("pages before status reads and keeps a missing checkout discoverable", () => { + const firstPath = "/worktrees/project/a-missing"; + const secondPath = "/worktrees/project/b"; + const harness = makeHarness({ + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: firstPath, refName: "feature/a" }, + { path: secondPath, refName: "feature/b" }, + ], + localStatusFailsOnCall: 1, + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { cursor: 1, limit: 1 }); + expect(result).toMatchObject({ total: 3, nextCursor: 2 }); + expect(result.worktrees).toEqual([ + expect.objectContaining({ + path: firstPath, + availability: "missing", + actualBranch: null, + isRepo: false, + }), + ]); + expect(harness.localStatus).toHaveBeenCalledTimes(1); + }); + }); + + it.effect("reports typed status failures without swallowing defects or interruption", () => + Effect.gen(function* () { + const typedResult = yield* runList(makeHarness({ localStatusFailure: "typed" }), { + limit: 1, + }); + expect(typedResult.worktrees[0]).toMatchObject({ availability: "missing" }); + + const defectExit = yield* Effect.exit( + runList(makeHarness({ localStatusFailure: "defect" }), { limit: 1 }), + ); + expect(Exit.isFailure(defectExit)).toBe(true); + if (Exit.isFailure(defectExit)) { + expect(Cause.hasDies(defectExit.cause)).toBe(true); + } + + const interruptExit = yield* Effect.exit( + runList(makeHarness({ localStatusFailure: "interrupt" }), { limit: 1 }), + ); + expect(Exit.isFailure(interruptExit)).toBe(true); + if (Exit.isFailure(interruptExit)) { + expect(Cause.hasInterruptsOnly(interruptExit.cause)).toBe(true); + } + }), + ); + + it.effect("marks a stale checkout missing when status reports a non-repository path", () => { + const stalePath = "/worktrees/project/stale"; + const harness = makeHarness({ + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: stalePath, refName: "feature/stale" }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [stalePath]: { branch: null, isRepo: false }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness); + expect(result.worktrees).toContainEqual( + expect.objectContaining({ + path: stalePath, + availability: "missing", + statusError: "Worktree path does not exist.", + actualBranch: null, + isRepo: false, + }), + ); + }); + }); + + it.effect("bounds returned bindings while reporting the total", () => { + const otherOne = ThreadId.make("thread-binding-one"); + const otherTwo = ThreadId.make("thread-binding-two"); + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { id: otherOne, title: "Other one", branch: "dev", worktreePath: null }, + { id: otherTwo, title: "Other two", branch: "dev", worktreePath: null }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { bindingLimit: 1 }); + expect(result.worktrees[0]?.bindingCount).toBe(3); + expect(result.worktrees[0]?.bindings).toHaveLength(1); + }); + }); + + it.effect("bounds nested binding identity reads by the requested page", () => { + const nestedOne = `${workspaceRoot}/packages/one`; + const nestedTwo = `${workspaceRoot}/packages/two`; + const nestedThree = `${workspaceRoot}/packages/three`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: ThreadId.make("thread-nested-binding-one"), + title: "Nested one", + branch: "dev", + worktreePath: nestedOne, + }, + { + id: ThreadId.make("thread-nested-binding-two"), + title: "Nested two", + branch: "dev", + worktreePath: nestedTwo, + }, + { + id: ThreadId.make("thread-nested-binding-three"), + title: "Nested three", + branch: "dev", + worktreePath: nestedThree, + }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1, bindingLimit: 1 }); + + expect(result.bindingPathResolution).toEqual({ + totalCandidates: 3, + attemptedCandidates: 1, + truncated: true, + complete: false, + }); + expect(result.worktrees[0]?.bindingCount).toBe(2); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + }); + }); + + it.effect("resolves only nested binding candidates for the selected worktree page", () => { + const firstWorktree = "/worktrees/project-a"; + const secondWorktree = "/worktrees/project-b"; + const firstNestedPath = `${firstWorktree}/packages/app`; + const secondNestedPath = `${secondWorktree}/packages/app`; + const listedWorktrees = [ + { path: workspaceRoot, refName: "dev" }, + { path: firstWorktree, refName: "feature/a" }, + { path: secondWorktree, refName: "feature/b" }, + ]; + const harness = makeHarness({ + worktrees: listedWorktrees, + projectThreads: [ + { + id: ThreadId.make("thread-off-page-nested-binding"), + title: "Off-page nested binding", + branch: "feature/a", + worktreePath: firstNestedPath, + }, + { + id: threadId, + title: "Selected-page nested binding", + branch: "feature/b", + worktreePath: secondNestedPath, + }, + ], + worktreeInventories: { + [firstNestedPath]: { + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: firstWorktree, + worktrees: listedWorktrees, + }, + [secondNestedPath]: { + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: secondWorktree, + worktrees: listedWorktrees, + }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { cursor: 2, limit: 1, bindingLimit: 1 }); + + expect(result.bindingPathResolution).toEqual({ + totalCandidates: 1, + attemptedCandidates: 1, + truncated: false, + complete: true, + }); + expect(result.worktrees[0]).toMatchObject({ + path: secondWorktree, + bindingCount: 1, + bindings: [expect.objectContaining({ threadId })], + }); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + expect(harness.listWorktrees).not.toHaveBeenCalledWith(firstNestedPath); + expect(harness.listWorktrees).toHaveBeenCalledWith(secondNestedPath); + }); + }); + + it.effect("reports incomplete binding counts when a candidate inventory read fails", () => { + const nestedPath = `${workspaceRoot}/packages/unreadable`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { + id: threadId, + title: "Unreadable nested binding", + branch: "dev", + worktreePath: nestedPath, + }, + ], + worktreeInventoryFailsFor: new Set([nestedPath]), + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1 }); + + expect(result.bindingPathResolution).toEqual({ + totalCandidates: 1, + attemptedCandidates: 1, + truncated: false, + complete: false, + }); + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 0, + bindings: [], + }); + }); + }); + + it.effect("includes archived thread bindings retained on a physical checkout", () => { + const archivedThreadId = ThreadId.make("thread-archived-list-owner"); + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + archivedProjectThread: { + id: archivedThreadId, + title: "Archived checkout owner", + branch: "dev", + worktreePath: workspaceRoot, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1 }); + + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 2, + bindings: expect.arrayContaining([ + expect.objectContaining({ + threadId: archivedThreadId, + recordedWorktreePath: workspaceRoot, + active: false, + }), + ]), + }); + }); + }); + + it.effect("attributes a nested recorded cwd to its physical worktree root", () => { + const nestedPath = `${workspaceRoot}/packages/app`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { + id: threadId, + title: "Nested caller", + branch: "dev", + worktreePath: nestedPath, + }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1 }); + + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 1, + bindings: [ + { + threadId, + recordedWorktreePath: nestedPath, + callingThread: true, + }, + ], + }); + expect(harness.localStatus).toHaveBeenCalledTimes(1); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + }); + }); + + it.effect("does not attribute a nested independent repository to the project worktree", () => { + const nestedPath = `${workspaceRoot}/vendor/independent`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { + id: threadId, + title: "Nested independent repository", + branch: "main", + worktreePath: nestedPath, + }, + ], + worktreeInventories: { + [nestedPath]: { + repositoryCommonDir: `${nestedPath}/.git`, + currentWorktreeRoot: nestedPath, + worktrees: [{ path: nestedPath, refName: "main" }], + }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1 }); + + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 0, + bindings: [], + }); + expect(harness.localStatus).toHaveBeenCalledTimes(1); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + }); + }); +}); + describe("WorktreeMcpHandoffInput schema", () => { const decode = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 4b8803b27f8e..a0bce0c8503a 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1,11 +1,14 @@ import { CommandId, MessageId, + type OrchestrationV2ThreadShell, type ProjectId, WorktreeMcpFailure, type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, type WorktreeMcpHandoffResult, + type WorktreeMcpListInput, + type WorktreeMcpListResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, } from "@t3tools/contracts"; @@ -13,6 +16,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; 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"; @@ -35,6 +39,10 @@ export class WorktreeMcpService extends Context.Service< readonly status: ( scope: McpInvocationScope, ) => Effect.Effect; + readonly listWorktrees: ( + scope: McpInvocationScope, + input: WorktreeMcpListInput, + ) => Effect.Effect; } >()("t3/mcp/WorktreeMcpService") {} @@ -57,6 +65,7 @@ const asOperationFailed = (prefix: string) => const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const threadManagement = yield* ThreadManagementService; const projects = yield* ProjectService.ProjectService; @@ -115,6 +124,48 @@ const make = Effect.gen(function* () { asOperationFailed("Unable to read server settings"), ); + const normalizePath = (value: string) => path.normalize(path.resolve(value)); + + const canonicalizePath = (value: string) => { + const normalized = normalizePath(value); + return fileSystem.realPath(normalized).pipe(Effect.orElseSucceed(() => normalized)); + }; + + const threadWorkspacePath = Effect.fn("WorktreeMcpService.threadWorkspacePath")(function* ( + thread: Pick, + projectWorkspaceRoot: string, + ) { + return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); + }); + + const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* ( + projectWorkspaceRoot: string, + ) { + return yield* gitWorkflow + .listWorktrees(projectWorkspaceRoot) + .pipe(asOperationFailed("Unable to list project worktrees")); + }); + + const loadProjectThreads = ( + projectId: ProjectId, + ): Effect.Effect, WorktreeMcpFailure> => + threadManagement.getShellSnapshot().pipe( + Effect.map((snapshot) => + [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => thread.projectId === projectId, + ), + ), + asOperationFailed(`Unable to list threads in project ${projectId}`), + ); + + const readWorkspaceStatus = (workspacePath: string) => + gitWorkflow + .invalidateLocalStatus(workspacePath) + .pipe( + Effect.andThen(gitWorkflow.localStatus({ cwd: workspacePath })), + asOperationFailed(`Unable to read git status in '${workspacePath}'`), + ); + const handoffIds = (scope: McpInvocationScope) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => { @@ -460,27 +511,263 @@ const make = Effect.gen(function* () { yield* requireCapability(scope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); - - const defaultStartFromOrigin = yield* readDefaultStartFromOrigin; + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot); + const [ + defaultStartFromOrigin, + actual, + projectInventory, + workspaceInventory, + workspaceExists, + ] = yield* Effect.all( + [ + readDefaultStartFromOrigin, + readWorkspaceStatus(workspacePath), + Effect.option(loadWorktrees(projectWorkspaceRoot)), + Effect.option(loadWorktrees(workspacePath)), + fileSystem.exists(workspacePath).pipe(Effect.orElseSucceed(() => false)), + ], + { concurrency: 5 }, + ); + const canonicalWorkspacePath = yield* canonicalizePath(workspacePath); + const physicalWorkspacePath = Option.isSome(workspaceInventory) + ? workspaceInventory.value.currentWorktreeRoot + : null; + const agreement = + !actual.isRepo && !workspaceExists + ? "workspace_missing" + : !actual.isRepo + ? "not_repository" + : Option.isNone(projectInventory) || Option.isNone(workspaceInventory) + ? "workspace_missing" + : workspaceInventory.value.repositoryCommonDir !== + projectInventory.value.repositoryCommonDir || + physicalWorkspacePath === null || + !projectInventory.value.worktrees.some( + (worktree) => worktree.path === physicalWorkspacePath, + ) + ? "workspace_missing" + : actual.refName !== projection.thread.branch + ? "branch_mismatch" + : "in_sync"; const result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, worktreePath: projection.thread.worktreePath, branch: projection.thread.branch, - projectWorkspaceRoot: project.workspaceRoot, + projectWorkspaceRoot, defaultStartFromOrigin, + recordedWorkspace: { + branch: projection.thread.branch, + worktreePath: projection.thread.worktreePath, + }, + actualWorkspace: { + workspacePath: physicalWorkspacePath ?? canonicalWorkspacePath, + isRepo: actual.isRepo, + branch: actual.refName, + hasWorkingTreeChanges: actual.hasWorkingTreeChanges, + }, + agreement, }; return result; }, ); - return WorktreeMcpService.of({ handoff, status }); + const listWorktrees: WorktreeMcpService["Service"]["listWorktrees"] = Effect.fn( + "WorktreeMcpService.listWorktrees", + )(function* (scope, input) { + yield* requireCapability(scope); + const projection = yield* loadThread(scope); + const project = yield* loadProject(scope, projection.thread.projectId); + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + const [inventory, threads] = yield* Effect.all( + [loadWorktrees(projectWorkspaceRoot), loadProjectThreads(projection.thread.projectId)], + { concurrency: 2 }, + ); + const projectWorktreeRoot = inventory.currentWorktreeRoot ?? projectWorkspaceRoot; + + const branchByWorkspacePath = new Map(); + for (const worktree of inventory.worktrees) { + branchByWorkspacePath.set(worktree.path, worktree.refName); + } + if (!branchByWorkspacePath.has(projectWorktreeRoot)) { + branchByWorkspacePath.set(projectWorktreeRoot, null); + } + const allWorktrees = [...branchByWorkspacePath.entries()].toSorted( + ([leftPath], [rightPath]) => + Number(rightPath === projectWorktreeRoot) - Number(leftPath === projectWorktreeRoot) || + leftPath.localeCompare(rightPath), + ); + const cursor = Math.min(input.cursor ?? 0, allWorktrees.length); + const limit = input.limit ?? 20; + const selectedWorktrees = allWorktrees.slice(cursor, cursor + limit); + const nextCursor = + cursor + selectedWorktrees.length < allWorktrees.length + ? cursor + selectedWorktrees.length + : null; + const bindingLimit = input.bindingLimit ?? 20; + const recordedThreadWorkspaces = yield* Effect.forEach(threads, (thread) => + threadWorkspacePath(thread, projectWorktreeRoot).pipe( + Effect.map((recordedPath) => [thread, recordedPath] as const), + ), + ); + const unresolvedRecordedPaths = [ + ...new Set( + recordedThreadWorkspaces + .map(([, recordedPath]) => recordedPath) + .filter((recordedPath) => !branchByWorkspacePath.has(recordedPath)), + ), + ]; + const selectedWorkspacePaths = new Set( + selectedWorktrees.map(([workspacePath]) => workspacePath), + ); + const isWithinWorkspace = (workspacePath: string, candidatePath: string) => { + const relative = path.relative(workspacePath, candidatePath); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; + const candidateRecordedPaths = unresolvedRecordedPaths.filter((recordedPath) => { + const nearestListedRoot = [...branchByWorkspacePath.keys()] + .filter((workspacePath) => isWithinWorkspace(workspacePath, recordedPath)) + .toSorted((left, right) => right.length - left.length)[0]; + return nearestListedRoot !== undefined && selectedWorkspacePaths.has(nearestListedRoot); + }); + const bindingPathResolutionLimit = Math.min(400, selectedWorktrees.length * bindingLimit); + const recordedPathsToResolve = candidateRecordedPaths.slice(0, bindingPathResolutionLimit); + const physicalRootByRecordedPath = new Map(); + const candidateResults = yield* Effect.forEach( + recordedPathsToResolve, + (recordedPath) => + Effect.option(loadWorktrees(recordedPath)).pipe( + Effect.map((candidateInventory) => ({ recordedPath, candidateInventory })), + ), + { concurrency: 8 }, + ); + let failedCandidateCount = 0; + for (const { recordedPath, candidateInventory } of candidateResults) { + if (Option.isNone(candidateInventory)) { + failedCandidateCount += 1; + continue; + } + const candidate = candidateInventory.value; + if ( + candidate.repositoryCommonDir === inventory.repositoryCommonDir && + candidate.currentWorktreeRoot !== null && + branchByWorkspacePath.has(candidate.currentWorktreeRoot) + ) { + physicalRootByRecordedPath.set(recordedPath, candidate.currentWorktreeRoot); + } + } + const threadWorkspaces = recordedThreadWorkspaces.map( + ([thread, recordedPath]) => + [thread, physicalRootByRecordedPath.get(recordedPath) ?? recordedPath] as const, + ); + + const worktrees = yield* Effect.forEach( + selectedWorktrees, + ([workspacePath, branch]) => + Effect.gen(function* () { + const bindings = threadWorkspaces + .filter(([, threadPath]) => threadPath === workspacePath) + .map(([thread]) => ({ + threadId: thread.id, + title: thread.title, + status: thread.status, + recordedBranch: thread.branch, + recordedWorktreePath: thread.worktreePath, + active: thread.activeRunId !== null, + callingThread: thread.id === scope.threadId, + })); + const statusResult = yield* readWorkspaceStatus(workspacePath).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "failure" as const, error }), + onSuccess: (status) => ({ _tag: "success" as const, status }), + }), + ); + if (statusResult._tag === "failure") { + const exists = yield* fileSystem + .exists(workspacePath) + .pipe(Effect.orElseSucceed(() => false)); + const detail = errorMessage(statusResult.error); + yield* Effect.logWarning("unable to read listed worktree status", { + workspacePath, + detail, + }); + return { + path: workspacePath, + branch, + actualBranch: null, + isRepo: false, + isProjectRoot: workspacePath === projectWorktreeRoot, + hasWorkingTreeChanges: false, + availability: exists ? "unreadable" : "missing", + statusError: detail, + bindings: bindings.slice(0, bindingLimit), + bindingCount: bindings.length, + } as const; + } + const actual = statusResult.status; + if (!actual.isRepo) { + const exists = yield* fileSystem + .exists(workspacePath) + .pipe(Effect.orElseSucceed(() => false)); + return { + path: workspacePath, + branch, + actualBranch: actual.refName, + isRepo: false, + isProjectRoot: workspacePath === projectWorktreeRoot, + hasWorkingTreeChanges: actual.hasWorkingTreeChanges, + availability: exists ? "unreadable" : "missing", + statusError: exists ? "Path is not a Git worktree." : "Worktree path does not exist.", + bindings: bindings.slice(0, bindingLimit), + bindingCount: bindings.length, + } as const; + } + return { + path: workspacePath, + branch, + actualBranch: actual.refName, + isRepo: actual.isRepo, + isProjectRoot: workspacePath === projectWorktreeRoot, + hasWorkingTreeChanges: actual.hasWorkingTreeChanges, + availability: "available", + statusError: null, + bindings: bindings.slice(0, bindingLimit), + bindingCount: bindings.length, + } as const; + }), + { concurrency: 8 }, + ); + + return { + projectWorkspaceRoot, + repositoryCommonDir: inventory.repositoryCommonDir, + projectWorktreeRoot, + bindingPathResolution: { + totalCandidates: candidateRecordedPaths.length, + attemptedCandidates: recordedPathsToResolve.length, + truncated: recordedPathsToResolve.length < candidateRecordedPaths.length, + complete: + recordedPathsToResolve.length === candidateRecordedPaths.length && + failedCandidateCount === 0, + }, + worktrees, + nextCursor, + total: allWorktrees.length, + } satisfies WorktreeMcpListResult; + }); + + return WorktreeMcpService.of({ handoff, status, listWorktrees }); }); export const layer: Layer.Layer< WorktreeMcpService, never, | Crypto.Crypto + | FileSystem.FileSystem | Path.Path | ThreadManagementService | ProjectService.ProjectService diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index b75e0c5dfbef..8d2bc64988dd 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -17,6 +17,12 @@ const handlers = { const service = yield* WorktreeMcpService; return yield* service.status(scope); }), + t3_worktree_list: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* WorktreeMcpService; + return yield* service.listWorktrees(scope, input); + }), } satisfies Parameters[0]; export const WorktreeToolkitHandlersLive = WorktreeToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 3300a869fe67..fd10d3f589a0 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -110,6 +110,7 @@ it.effect("production mcp layer lists worktree tools over http", () => const toolNames = tools.map((tool) => tool.name); expect(toolNames).toContain("t3_worktree_handoff"); expect(toolNames).toContain("t3_worktree_status"); + expect(toolNames).toContain("t3_worktree_list"); // The worktree registration merges alongside the other toolkits rather // than replacing them. expect(toolNames).toContain("preview_status"); @@ -125,6 +126,9 @@ it.effect("production mcp layer lists worktree tools over http", () => const status = tools.find((tool) => tool.name === "t3_worktree_status"); expect(status?.annotations?.readOnlyHint).toBe(true); expect(status?.annotations?.destructiveHint).toBe(false); + const list = tools.find((tool) => tool.name === "t3_worktree_list"); + expect(list?.annotations?.readOnlyHint).toBe(true); + expect(list?.annotations?.destructiveHint).toBe(false); // MCP requires every tool input schema to be a top-level object schema. // A non-object schema (e.g. the anyOf produced by an empty diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 69a33cb79d07..092c88ab6c6b 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -2,6 +2,8 @@ import { WorktreeMcpFailure, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, + WorktreeMcpListInput, + WorktreeMcpListResult, WorktreeMcpStatusResult, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/unstable/ai"; @@ -28,7 +30,7 @@ export const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { export const WorktreeStatusTool = Tool.make("t3_worktree_status", { description: - "Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for t3_worktree_handoff's startFromOrigin. Call this before t3_worktree_handoff to check whether a handoff is possible or has already happened.", + "Report both the durable workspace recorded on this agent thread and the branch actually checked out on disk. The agreement field calls out a branch mismatch, a missing worktree binding, or a non-repository path. Call this before a handoff or checkout and after failures.", // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes // to a top-level `type: "object"` JSON Schema. An explicit empty // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a @@ -44,4 +46,23 @@ export const WorktreeStatusTool = Tool.make("t3_worktree_status", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); -export const WorktreeToolkit = Toolkit.make(WorktreeHandoffTool, WorktreeStatusTool); +export const WorktreeListTool = Tool.make("t3_worktree_list", { + description: + "Page through the calling thread's project root and Git-registered worktrees, including detached checkouts. Paths are canonicalized from Git's repository identity. Each entry includes the actual checked-out branch, dirty state, availability, and a bounded list plus total count of threads bound to that checkout. Use cursor until nextCursor is null. This tool does not create, remove, prune, or repair worktrees.", + parameters: WorktreeMcpListInput, + success: WorktreeMcpListResult, + failure: WorktreeMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "List project git worktrees") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const WorktreeToolkit = Toolkit.make( + WorktreeHandoffTool, + WorktreeStatusTool, + WorktreeListTool, +); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b422190fc04c..6d56554b83f4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -46,6 +46,7 @@ import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeComp import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -580,7 +581,10 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { }); it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { - const readOnlyToolNames = Object.values(OrchestratorToolkit.tools) + const readOnlyToolNames = [ + ...Object.values(OrchestratorToolkit.tools), + ...Object.values(WorktreeToolkit.tools), + ] .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) .map((tool) => `mcp__t3-code__${tool.name}`) .sort(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c0684ca2357c..791164da05c9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -801,13 +801,15 @@ export function makeClaudeQueryOptions(input: { export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; -// Must stay in sync with the Tool.Readonly annotations on OrchestratorToolkit; -// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit. +// Must stay in sync with the Tool.Readonly annotations on the orchestration +// and worktree toolkits; ClaudeAdapterV2.test.ts cross-checks this list. export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__orchestrator_capabilities", "mcp__t3-code__list_scheduled_tasks", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", + "mcp__t3-code__t3_worktree_list", + "mcp__t3-code__t3_worktree_status", ]; // The SDK's `allowedTools` only pre-approves tool calls; availability is the diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 23eb29dc667c..37a45608c900 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -245,6 +245,17 @@ export interface GitRemoteStatusOptions { readonly refreshUpstream?: boolean; } +export interface GitWorktreeCheckout { + readonly path: string; + readonly refName: string | null; +} + +export interface GitWorktreeInventory { + readonly repositoryCommonDir: string; + readonly currentWorktreeRoot: string | null; + readonly worktrees: ReadonlyArray; +} + export class GitVcsDriver extends Context.Service< GitVcsDriver, { @@ -291,6 +302,7 @@ export class GitVcsDriver extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listWorktrees: (cwd: string) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8a1f0d966420..b0bb6ee6ef0b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -523,8 +523,12 @@ it.effect("ignores worktree metadata for directories that no longer exist", () = ); const refs = yield* driver.listRefs({ cwd, refresh: true }); + const inventory = yield* driver.listWorktrees(cwd); assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); + assert.deepEqual(inventory.worktrees, [ + { path: missingWorktreePath, refName: "stale-worktree" }, + ]); }), ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); @@ -1559,6 +1563,64 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("lists canonical attached and detached worktrees through a symlinked checkout", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const detachedPath = pathService.join(worktreesRoot, "detached"); + const linksRoot = yield* makeTmpDir("git-vcs-driver-links-"); + const checkoutLink = pathService.join(linksRoot, "checkout"); + const nestedDirectory = pathService.join(cwd, "packages", "server"); + yield* fileSystem.makeDirectory(nestedDirectory, { recursive: true }); + yield* git(cwd, ["worktree", "add", "--detach", detachedPath, "HEAD"]); + yield* fileSystem.symlink(cwd, checkoutLink); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const inventory = yield* driver.listWorktrees( + pathService.join(checkoutLink, "packages", "server"), + ); + + assert.deepEqual( + inventory.worktrees.toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: yield* fileSystem.realPath(cwd), refName: initialBranch }, + { path: yield* fileSystem.realPath(detachedPath), refName: null }, + ].toSorted((left, right) => left.path.localeCompare(right.path)), + ); + assert.equal(inventory.currentWorktreeRoot, yield* fileSystem.realPath(cwd)); + assert.equal( + inventory.repositoryCommonDir, + yield* fileSystem.realPath(pathService.join(cwd, ".git")), + ); + }), + ); + + it.effect("resolves a nested repository independently from its containing checkout", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const nestedDirectory = pathService.join(cwd, "vendor", "independent"); + yield* fileSystem.makeDirectory(nestedDirectory, { recursive: true }); + yield* initRepoWithCommit(nestedDirectory); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const outerInventory = yield* driver.listWorktrees(cwd); + const nestedInventory = yield* driver.listWorktrees(nestedDirectory); + + assert.equal(outerInventory.currentWorktreeRoot, yield* fileSystem.realPath(cwd)); + assert.equal( + nestedInventory.currentWorktreeRoot, + yield* fileSystem.realPath(nestedDirectory), + ); + assert.notEqual(nestedInventory.repositoryCommonDir, outerInventory.repositoryCommonDir); + }), + ); + // NTFS rejects a newline in a file name, so there is nothing to preserve there. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", @@ -1567,6 +1629,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); const driver = yield* GitVcsDriver.GitVcsDriver; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1fd7918c883c..05bde710acb5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -247,19 +247,17 @@ function paginateBranches(input: { }; } -function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { - const worktreePaths = new Map(); +function parseWorktreeCheckouts(stdout: string): ReadonlyArray { + const worktrees: Array = []; let currentPath: string | null = null; let currentBranch: string | null = null; - let currentPrunable = false; const flush = () => { - if (currentPath !== null && currentBranch !== null && !currentPrunable) { - worktreePaths.set(currentBranch, currentPath); + if (currentPath !== null) { + worktrees.push({ path: currentPath, refName: currentBranch }); } currentPath = null; currentBranch = null; - currentPrunable = false; }; for (const field of stdout.split("\0")) { @@ -269,13 +267,11 @@ function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { currentPath = field.slice("worktree ".length); } else if (field.startsWith("branch refs/heads/")) { currentBranch = field.slice("branch refs/heads/".length); - } else if (field === "prunable" || field.startsWith("prunable ")) { - currentPrunable = true; } } flush(); - return worktreePaths; + return worktrees; } function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { @@ -1134,7 +1130,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { concurrency: 2 }, ); const worktreeRootOutput = worktreeRootResult.stdout.trim(); - const worktreeRoot = + const resolvedWorktreeRoot = worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 ? path.normalize( path.isAbsolute(worktreeRootOutput) @@ -1142,6 +1138,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : path.resolve(cwd, worktreeRootOutput), ) : null; + const worktreeRoot = + resolvedWorktreeRoot === null + ? null + : yield* fileSystem + .realPath(resolvedWorktreeRoot) + .pipe(Effect.orElseSucceed(() => resolvedWorktreeRoot)); const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 @@ -2581,11 +2583,69 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const readGitWorktrees = Effect.fn("GitVcsDriver.readGitWorktrees")(function* ( + gitCommonDir: string, + tolerateFailure = false, + ) { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + const worktreeListResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.listWorktrees", + fetchCwd, + ["--git-dir", gitCommonDir, "worktree", "list", "--porcelain", "-z"], + { + allowNonZeroExit: tolerateFailure, + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + fallbackErrorDetail: "Git worktree enumeration failed.", + }, + ); + if (worktreeListResult.exitCode !== 0) { + return []; + } + const parsedWorktreeEntries = parseWorktreeCheckouts(worktreeListResult.stdout).map( + (worktree) => ({ + ...worktree, + path: path.normalize(path.resolve(worktree.path)), + }), + ); + return yield* Effect.forEach( + parsedWorktreeEntries, + (worktree) => + fileSystem.realPath(worktree.path).pipe( + Effect.map((canonicalPath) => ({ ...worktree, path: canonicalPath })), + Effect.orElseSucceed(() => worktree), + ), + { concurrency: 16 }, + ); + }); + + const listWorktrees: GitVcsDriver.GitVcsDriver["Service"]["listWorktrees"] = Effect.fn( + "GitVcsDriver.listWorktrees", + )(function* (cwd) { + const repositoryPaths = yield* resolveRepositoryPaths(cwd, true); + if (repositoryPaths === null) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.listWorktrees", + cwd, + args: ["worktree", "list", "--porcelain", "-z"], + }), + detail: "The requested directory is not inside a Git repository.", + }); + } + return { + repositoryCommonDir: repositoryPaths.gitCommonDir, + currentWorktreeRoot: repositoryPaths.worktreeRoot, + worktrees: yield* readGitWorktrees(repositoryPaths.gitCommonDir), + }; + }); + const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; const gitDirArgs = ["--git-dir", gitCommonDir] as const; - const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all( + const [refsResult, defaultRefResult, worktrees, remoteNamesResult] = yield* Effect.all( [ executeGitWithStableDiagnostics( "GitVcsDriver.listRefs.snapshotRefs", @@ -2612,16 +2672,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ), - executeGit( - "GitVcsDriver.listRefs.worktreeList", - fetchCwd, - [...gitDirArgs, "worktree", "list", "--porcelain", "-z"], - { - timeoutMs: 30_000, - allowNonZeroExit: true, - maxOutputBytes: 16 * 1024 * 1024, - }, - ), + readGitWorktrees(gitCommonDir, true), executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -2641,23 +2692,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* defaultRefResult.exitCode === 0 ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; - const parsedWorktreeEntries = - worktreeListResult.exitCode === 0 - ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( - ([branchName, worktreePath]) => - [branchName, path.normalize(path.resolve(worktreePath))] as const, - ) - : []; - const existingWorktreeEntries = yield* Effect.filter( - parsedWorktreeEntries, - ([, worktreePath]) => - fileSystem.stat(worktreePath).pipe( + const existingWorktrees = yield* Effect.filter( + worktrees, + (worktree) => + fileSystem.stat(worktree.path).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ), { concurrency: 16 }, ); - const worktreeMap = new Map(existingWorktreeEntries); + const worktreeMap = new Map( + existingWorktrees.flatMap((worktree) => + worktree.refName === null ? [] : ([[worktree.refName, worktree.path]] as const), + ), + ); const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; @@ -3389,6 +3437,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, + listWorktrees, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 215a350a2f04..87c75cf4bad6 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -141,7 +141,7 @@ selection model-visible without allowing a request that cannot run. ## Tool Surface -The server exposes eleven orchestration tools. +The server exposes orchestration and thread-scoped workspace tools. ### `orchestrator_capabilities` @@ -327,6 +327,32 @@ Without `runId`, it selects the newest interruptible run. A terminal run is returned unchanged, and a thread with no active provider turn returns `no_active_run`. +### `t3_worktree_status` + +Reads the calling thread's saved branch and worktree path, then reads Git status from that path. +The result keeps recorded and actual state separate and reports whether they agree. A missing +worktree, non-repository path, or branch mismatch is visible without changing either state. + +### `t3_worktree_list` + +Lists the project root and every Git-registered worktree in the calling thread's current project, +including detached checkouts. Git's canonical common-directory inventory and real paths determine +repository membership, so symlinked paths and saved branch labels are not treated as proof. Each +result distinguishes the project's configured execution directory from its canonical physical +worktree root and Git common directory. Each entry includes its listed and actual branch, dirty +state, and threads bound to the checkout. +Bindings keep each thread's recorded branch and worktree path separate from the actual checkout. +Results are paginated before status reads, and each entry returns a bounded binding list with its +full binding count when every nested or aliased recorded path for that page was resolved. Path +resolution first selects recorded paths whose nearest listed physical checkout is on the requested +page, then verifies each candidate through Git repository and worktree identity. The work is bounded +by the page and binding limits, with a hard ceiling of 400 lookups. The result reports how many +page candidates were attempted, whether the candidate list was truncated, and whether resolution +completed without a Git inventory failure. Binding counts are lower bounds unless resolution is +complete. A missing or unreadable checkout remains in the page with an availability and error +instead of failing discovery of the other worktrees. The tool does not create, remove, prune, or +repair worktrees. + ## Delegated Task Lifecycle The MCP server is a command ingress into V2. It does not call provider adapters @@ -370,6 +396,8 @@ falls back to a terminal-status message when no assistant text exists. - General thread management is limited to the calling thread's project. Send additionally enforces the same runtime and interaction privilege ceiling as child creation. +- Workspace discovery is limited to the calling thread's current project. It does not accept an + environment or cross-project target. - Provider instances must be enabled, installed, available, authenticated, and backed by a V2 adapter. - A requested model must be advertised by the selected provider when the diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2d7ed8badbec..c6f68d1d0cde 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -70,6 +70,17 @@ messages, review titles, and descriptions from your changes. Choose the writing style and model in **Settings → Source Control**. **Repository conventions** uses the project's instructions and recent commit subjects. +### Let an agent inspect its checkout + +Agents running through T3 Code can inspect the checkout recorded on their thread and compare it +with Git's actual branch. They can also list the project root and Git-registered worktrees, +including detached checkouts, dirty state, and the durable branch and worktree path recorded for +other threads using each checkout. Git resolves symlinked checkout paths through the repository's +real common-directory and physical-worktree identity, including when a project opens in a nested +folder. Worktree results are paginated, and missing or unreadable checkouts are +reported without hiding the rest. These read paths apply only to the calling thread's current +project and do not create, remove, prune, or revive worktrees. + ## Review and merge Open **Pull requests** to review changes and comments, request reviewers, check out a branch, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index d7a4692fc317..740dd97a6b08 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -612,7 +612,7 @@ describe("cached VCS refs", () => { ), ); - it.effect("emits persisted refs before a live refresh", () => + it.effect("emits a persisted legacy branch-list shape before a live refresh", () => Effect.scoped( Effect.gen(function* () { const client = { diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..08fc17d114b6 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { VcsCreateWorktreeInput, + VcsListRefsResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitRunStackedActionResult, @@ -20,6 +21,27 @@ const decodePreparePullRequestThreadResult = Schema.decodeUnknownSync( const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); +const decodeListRefsResult = Schema.decodeUnknownSync(VcsListRefsResult); + +describe("VcsListRefsResult", () => { + it("decodes the established branch-list response without worktree inventory", () => { + expect( + decodeListRefsResult({ + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 0, + }), + ).toEqual({ + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 0, + }); + }); +}); describe("VcsCreateWorktreeInput", () => { it("accepts omitted newRefName for existing-refName worktrees", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 4b63b877923f..daee47bcc204 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -88,6 +88,7 @@ const VcsWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, }); + const GitResolvedPullRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 154839fae6bb..df0528e89dc1 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Input for the `t3_worktree_handoff` MCP tool. @@ -95,6 +95,28 @@ export const WorktreeMcpHandoffResult = Schema.Struct({ }); export type WorktreeMcpHandoffResult = typeof WorktreeMcpHandoffResult.Type; +export const WorktreeMcpRecordedWorkspace = Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), +}); +export type WorktreeMcpRecordedWorkspace = typeof WorktreeMcpRecordedWorkspace.Type; + +export const WorktreeMcpActualWorkspace = Schema.Struct({ + workspacePath: TrimmedNonEmptyString, + isRepo: Schema.Boolean, + branch: Schema.NullOr(TrimmedNonEmptyString), + hasWorkingTreeChanges: Schema.Boolean, +}); +export type WorktreeMcpActualWorkspace = typeof WorktreeMcpActualWorkspace.Type; + +export const WorktreeMcpWorkspaceAgreement = Schema.Literals([ + "in_sync", + "branch_mismatch", + "workspace_missing", + "not_repository", +]); +export type WorktreeMcpWorkspaceAgreement = typeof WorktreeMcpWorkspaceAgreement.Type; + export const WorktreeMcpStatusResult = Schema.Struct({ attached: Schema.Boolean.annotate({ description: "True when this thread is already attached to a git worktree.", @@ -107,9 +129,60 @@ export const WorktreeMcpStatusResult = Schema.Struct({ defaultStartFromOrigin: Schema.Boolean.annotate({ description: "Server default used by t3_worktree_handoff when startFromOrigin is omitted.", }), + recordedWorkspace: WorktreeMcpRecordedWorkspace, + actualWorkspace: WorktreeMcpActualWorkspace, + agreement: WorktreeMcpWorkspaceAgreement, }); export type WorktreeMcpStatusResult = typeof WorktreeMcpStatusResult.Type; +export const WorktreeMcpThreadBinding = Schema.Struct({ + threadId: ThreadId, + title: Schema.String, + status: TrimmedNonEmptyString, + recordedBranch: Schema.NullOr(TrimmedNonEmptyString), + recordedWorktreePath: Schema.NullOr(TrimmedNonEmptyString), + active: Schema.Boolean, + callingThread: Schema.Boolean, +}); +export type WorktreeMcpThreadBinding = typeof WorktreeMcpThreadBinding.Type; + +export const WorktreeMcpListInput = Schema.Struct({ + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50))), + bindingLimit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50))), +}); +export type WorktreeMcpListInput = typeof WorktreeMcpListInput.Type; + +export const WorktreeMcpListEntry = Schema.Struct({ + path: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + actualBranch: Schema.NullOr(TrimmedNonEmptyString), + isRepo: Schema.Boolean, + isProjectRoot: Schema.Boolean, + hasWorkingTreeChanges: Schema.Boolean, + availability: Schema.Literals(["available", "missing", "unreadable"]), + statusError: Schema.NullOr(Schema.String), + bindings: Schema.Array(WorktreeMcpThreadBinding), + bindingCount: NonNegativeInt, +}); +export type WorktreeMcpListEntry = typeof WorktreeMcpListEntry.Type; + +export const WorktreeMcpListResult = Schema.Struct({ + projectWorkspaceRoot: TrimmedNonEmptyString, + repositoryCommonDir: TrimmedNonEmptyString, + projectWorktreeRoot: TrimmedNonEmptyString, + bindingPathResolution: Schema.Struct({ + totalCandidates: NonNegativeInt, + attemptedCandidates: NonNegativeInt, + truncated: Schema.Boolean, + complete: Schema.Boolean, + }), + worktrees: Schema.Array(WorktreeMcpListEntry), + nextCursor: Schema.NullOr(NonNegativeInt), + total: NonNegativeInt, +}); +export type WorktreeMcpListResult = typeof WorktreeMcpListResult.Type; + export class WorktreeMcpFailure extends Schema.TaggedErrorClass()( "WorktreeMcpFailure", { diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index 765c4f6ca822..a54e51412f65 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -40,6 +40,10 @@ describe("resolveT3McpToolPresentation", () => { displayName: "Get thread worktree status", logo: "t3-code", }); + expect(resolveT3McpToolPresentation("mcp__t3-code__t3_worktree_list")).toEqual({ + displayName: "List project git worktrees", + logo: "t3-code", + }); }); it("pretty prints preview T3 MCP tool names", () => { diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index a9dcd685cc0c..f9dd44cf5b9b 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -54,6 +54,7 @@ const T3_MCP_TOOLS: Record< t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" }, t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, t3_worktree_status: { displayName: "Get thread worktree status" }, + t3_worktree_list: { displayName: "List project git worktrees" }, preview_status: { displayName: "Get preview browser status" }, preview_open: { displayName: "Open a page in the preview browser" }, preview_navigate: { displayName: "Navigate the preview browser" },