diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a8cb573ad88..514d5aa7383b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -31,6 +31,8 @@ import { } from "./toolkits/preview/tools.ts"; import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts"; +import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import { DeviceScreenshotToolkitHandlersLive, DeviceStandardToolkitHandlersLive, @@ -608,6 +610,10 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest Layer.provide(PullRequestsToolkitHandlersLive), ); +const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe( + Layer.provide(WorktreeToolkitHandlersLive), +); + const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe( Layer.provide(DeviceStandardToolkitHandlersLive), ); @@ -631,5 +637,6 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, + WorktreeToolkitRegistrationLive, DeviceToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index eddfa7270a77..058fa6556f63 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -8,7 +8,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview" | "device" | "pull-requests"; +export type McpCapability = "preview" | "device" | "pull-requests" | "worktree"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index a1e333bd07b0..a214f321b39f 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -78,9 +78,9 @@ it.effect("always grants pull-requests and gates browser and device access indep .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, "")) .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort())); - expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]); - expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]); - expect(yield* capabilitiesOf(withDevice)).toEqual(["device", "pull-requests"]); + expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests", "worktree"]); + expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests", "worktree"]); + expect(yield* capabilitiesOf(withDevice)).toEqual(["device", "pull-requests", "worktree"]); }), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 383bfbfec390..c32fa44cbe8a 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -131,6 +131,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), capabilities: new Set([ "pull-requests", + "worktree", ...request.capabilities, ]), issuedAt, diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts new file mode 100644 index 000000000000..fdeb416819a2 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -0,0 +1,254 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + VcsProcessExitError, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type VcsRepositoryIdentity, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import type { Tool } from "effect/unstable/ai"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { VcsDriverRegistry, type VcsDriverHandle } from "../../../vcs/VcsDriverRegistry.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { WorktreeToolkitHandlersLive } from "./handlers.ts"; +import { WorktreeToolkit } from "./tools.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const PROJECT_ROOT = "/workspace/project"; +const LINKED_WORKTREE = "/workspace/project-worktrees/feature"; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(7), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +const invocation = ( + capabilities: ReadonlyArray, +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment-1"), + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(capabilities), + issuedAt: 1, +}); + +const project: OrchestrationProjectShell = { + id: PROJECT_ID, + title: "Project", + workspaceRoot: PROJECT_ROOT, + defaultModelSelection: null, + scripts: [], + repositoryIdentity: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +const thread: OrchestrationThreadShell = { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}; + +const freshness: VcsRepositoryIdentity["freshness"] = { + source: "live-local", + observedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), + expiresAt: Option.none(), +}; + +/** Git reports the main checkout's common dir relative to it and a linked worktree's as absolute. */ +const repositories: Record = { + [PROJECT_ROOT]: { kind: "git", rootPath: PROJECT_ROOT, metadataPath: ".git", freshness }, + [LINKED_WORKTREE]: { + kind: "git", + rootPath: LINKED_WORKTREE, + metadataPath: `${PROJECT_ROOT}/.git`, + freshness, + }, + "/workspace/other": { + kind: "git", + rootPath: "/workspace/other", + metadataPath: ".git", + freshness, + }, +}; + +const makeHarness = Effect.fn("makeWorktreeToolkitHarness")(function* ( + options: { readonly headBranch?: string | null; readonly headExitCode?: number } = {}, +) { + const commands = yield* Ref.make>([]); + const headBranch = options.headBranch === undefined ? "feature" : options.headBranch; + // `git symbolic-ref --quiet` exits 1 for a detached HEAD and 128 for other failures. + const headExitCode = options.headExitCode ?? (headBranch === null ? 1 : 0); + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Ref.update(commands, (recorded) => [...recorded, command]).pipe(Effect.as({ sequence: 1 })); + const driver = { + execute: () => + Effect.succeed({ + exitCode: headExitCode, + stdout: headExitCode === 0 ? `${headBranch}\n` : "", + stderr: headExitCode === 128 ? "fatal: not a git repository\n" : "", + stdoutTruncated: false, + stderrTruncated: false, + }), + } as unknown as VcsDriverHandle["driver"]; + const detect: VcsDriverRegistry["Service"]["detect"] = ({ cwd }) => { + // A path inside a worktree resolves to that worktree, like git does. + const root = Object.keys(repositories).find( + (candidate) => cwd === candidate || cwd.startsWith(`${candidate}/`), + ); + if (root === undefined) { + return Effect.fail( + new VcsProcessExitError({ + operation: "test", + command: "git rev-parse", + cwd, + exitCode: 128, + detail: "not a git repository", + }), + ); + } + return Effect.succeed({ kind: "git", repository: repositories[root]!, driver }); + }; + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: (threadId) => + Effect.succeed(threadId === THREAD_ID ? Option.some(thread) : Option.none()), + getProjectShellById: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.mock(VcsDriverRegistry)({ + detect, + resolve: () => Effect.die("resolve is not used by the worktree toolkit"), + }), + Layer.succeed(Crypto.Crypto, testCrypto), + NodeServices.layer, + ); + const toolkit = yield* WorktreeToolkit.pipe( + Effect.provide(WorktreeToolkitHandlersLive.pipe(Layer.provide(dependencies))), + ); + const call = ( + params: { readonly path: string }, + capabilities: ReadonlyArray = ["worktree"], + ) => + toolkit.handle("t3_worktree_handoff", params).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map( + (chunk) => + chunk.at(-1)!.result as Tool.Success, + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)), + Effect.provide(dependencies), + ); + return { commands, call }; +}); + +describe("worktree toolkit handlers", () => { + it.effect("refuses a credential without the worktree capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call({ path: LINKED_WORKTREE }, ["pull-requests"]) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "McpCapabilityUnavailableError", + capability: "worktree", + }); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("binds the thread to a linked worktree of the project repository", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call({ path: `${LINKED_WORKTREE}/src` }); + expect(result).toEqual({ + worktreePath: LINKED_WORKTREE, + branch: "feature", + previousWorktreePath: null, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.meta.update", + threadId: THREAD_ID, + worktreePath: LINKED_WORKTREE, + branch: "feature", + }, + ]); + }), + ); + + it.effect("records a detached HEAD as no branch", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ headBranch: null }); + const result = yield* harness.call({ path: LINKED_WORKTREE }); + expect(result.branch).toBeNull(); + expect(yield* Ref.get(harness.commands)).toMatchObject([{ branch: null }]); + }), + ); + + it.effect("fails without touching the thread when HEAD cannot be read", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ headExitCode: 128 }); + const error = yield* harness.call({ path: LINKED_WORKTREE }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "WorktreeHandoffFailedError" }); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect.each([ + { path: "relative/worktree", detail: "Pass the worktree's absolute path." }, + { path: "/workspace/missing", detail: "does not exist or is not inside a git worktree" }, + { path: "/workspace/other", detail: "is not a worktree of this thread's project repository" }, + { path: PROJECT_ROOT, detail: "That is the project's own checkout" }, + ])("rejects $path without touching the thread", ({ path, detail }) => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness.call({ path }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "WorktreeHandoffPathInvalidError" }); + expect(error.message).toContain(detail); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts new file mode 100644 index 000000000000..4cb0e6278362 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -0,0 +1,153 @@ +import { + CommandId, + VcsProcessExitError, + type ThreadId, + type VcsRepositoryIdentity, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsDriverRegistry from "../../../vcs/VcsDriverRegistry.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { + WorktreeHandoffFailedError, + WorktreeHandoffPathInvalidError, + WorktreeHandoffThreadNotFoundError, + WorktreeToolkit, +} from "./tools.ts"; + +const make = Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const crypto = yield* Crypto.Crypto; + + const commandId = (threadId: ThreadId) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => CommandId.make(`server:mcp-worktree-handoff:${threadId}:${uuid}`)), + ); + + // Symlinked or 8.3 short paths must compare equal to their long form. + const canonical = (target: string) => + fileSystem.realPath(target).pipe( + Effect.orElseSucceed(() => target), + Effect.map((resolved) => path.normalize(resolved)), + ); + + // Git reports the common dir relative to the root for a main checkout and + // absolute for a linked worktree. + const commonDirOf = (repository: VcsRepositoryIdentity) => + repository.metadataPath === null + ? Effect.succeed(null) + : canonical(path.resolve(repository.rootPath, repository.metadataPath)); + + const detectGit = (cwd: string, detail: string) => + vcsRegistry.detect({ cwd, requestedKind: "git" }).pipe( + Effect.catch(() => Effect.succeed(null)), + Effect.flatMap((handle) => + handle === null ? new WorktreeHandoffPathInvalidError({ detail }) : Effect.succeed(handle), + ), + ); + + return WorktreeToolkit.of({ + t3_worktree_handoff: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.requireMcpCapability("worktree"); + const thread = yield* snapshots + .getThreadShellById(scope.threadId) + .pipe(Effect.mapError((cause) => new WorktreeHandoffFailedError({ cause }))); + if (Option.isNone(thread)) { + return yield* new WorktreeHandoffThreadNotFoundError({ threadId: scope.threadId }); + } + const project = yield* snapshots + .getProjectShellById(thread.value.projectId) + .pipe(Effect.mapError((cause) => new WorktreeHandoffFailedError({ cause }))); + if (Option.isNone(project)) { + return yield* new WorktreeHandoffThreadNotFoundError({ threadId: scope.threadId }); + } + if (!path.isAbsolute(input.path)) { + return yield* new WorktreeHandoffPathInvalidError({ + detail: "Pass the worktree's absolute path.", + }); + } + + const target = yield* detectGit( + input.path, + `${input.path} does not exist or is not inside a git worktree.`, + ); + const home = yield* detectGit( + project.value.workspaceRoot, + "This thread's project is not a git repository.", + ); + // Persist git's own spelling of the root, which is what session cwds and + // status lookups report; canonical forms are only for the checks below. + const worktreePath = path.normalize(target.repository.rootPath); + const canonicalRoot = yield* canonical(worktreePath); + if (canonicalRoot === (yield* canonical(home.repository.rootPath))) { + return yield* new WorktreeHandoffPathInvalidError({ + detail: + "That is the project's own checkout, which this thread uses when it has no worktree.", + }); + } + const targetCommonDir = yield* commonDirOf(target.repository); + if (targetCommonDir === null || targetCommonDir !== (yield* commonDirOf(home.repository))) { + return yield* new WorktreeHandoffPathInvalidError({ + detail: `${input.path} is not a worktree of this thread's project repository.`, + }); + } + + // With --quiet a detached HEAD exits 1 and any other failure exits 128. + // A detached HEAD has no branch; the thread then shows the worktree without one. + const operation = "WorktreeToolkit.headBranch"; + const head = yield* target.driver + .execute({ + operation, + cwd: worktreePath, + args: ["symbolic-ref", "--quiet", "--short", "HEAD"], + allowNonZeroExit: true, + }) + .pipe(Effect.mapError((cause) => new WorktreeHandoffFailedError({ cause }))); + if (head.exitCode !== 0 && head.exitCode !== 1) { + return yield* new WorktreeHandoffFailedError({ + cause: new VcsProcessExitError({ + operation, + command: "git symbolic-ref", + cwd: worktreePath, + exitCode: head.exitCode, + detail: head.stderr.trim() || "Could not read the worktree's HEAD.", + }), + }); + } + const branch = head.exitCode === 0 ? head.stdout.trim() || null : null; + + yield* engine + .dispatch({ + type: "thread.meta.update", + commandId: yield* commandId(thread.value.id), + threadId: thread.value.id, + worktreePath, + branch, + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail(new WorktreeHandoffFailedError({ cause })), + ), + ); + + return { worktreePath, branch, previousWorktreePath: thread.value.worktreePath }; + }), + }); +}); + +export const WorktreeToolkitHandlersLive = WorktreeToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts new file mode 100644 index 000000000000..e16711a1ff82 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -0,0 +1,90 @@ +import { McpCapabilityUnavailableError, TrimmedNonEmptyString } from "@t3tools/contracts"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsDriverRegistry from "../../../vcs/VcsDriverRegistry.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + OrchestrationEngine.OrchestrationEngineService, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + VcsDriverRegistry.VcsDriverRegistry, + FileSystem.FileSystem, + Path.Path, +]; + +export const WorktreeHandoffInput = Schema.Struct({ + path: TrimmedNonEmptyString.annotate({ + description: + "Absolute path of a linked git worktree of this thread's project repository. A path inside the worktree resolves to its root.", + }), +}); +export type WorktreeHandoffInput = typeof WorktreeHandoffInput.Type; + +export class WorktreeHandoffPathInvalidError extends Schema.TaggedError()( + "WorktreeHandoffPathInvalidError", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + +export class WorktreeHandoffThreadNotFoundError extends Schema.TaggedError()( + "WorktreeHandoffThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +export class WorktreeHandoffFailedError extends Schema.TaggedError()( + "WorktreeHandoffFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not hand the thread off to the worktree."; + } +} + +export const WorktreeToolError = Schema.Union([ + McpCapabilityUnavailableError, + WorktreeHandoffPathInvalidError, + WorktreeHandoffThreadNotFoundError, + WorktreeHandoffFailedError, +]); +export type WorktreeToolError = typeof WorktreeToolError.Type; + +export const WorktreeHandoffResult = Schema.Struct({ + worktreePath: Schema.String, + branch: Schema.NullOr(Schema.String).annotate({ + description: "Branch checked out in the worktree, or null for a detached HEAD.", + }), + previousWorktreePath: Schema.NullOr(Schema.String).annotate({ + description: "The worktree the thread was bound to before, or null for the project checkout.", + }), +}); +export type WorktreeHandoffResult = typeof WorktreeHandoffResult.Type; + +const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { + description: + "Point this thread at a git worktree you created for it. T3 Code then labels the thread as a worktree thread, opens that folder from the Open button, and restarts the provider session inside the worktree on the next turn. Call it right after `git worktree add` when you continue the task there. Only linked worktrees of this thread's project repository are accepted.", + parameters: WorktreeHandoffInput, + success: WorktreeHandoffResult, + failure: WorktreeToolError, + dependencies, +}) + .annotate(Tool.Title, "Hand thread off to a worktree") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const WorktreeToolkit = Toolkit.make(WorktreeHandoffTool); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 927d2a7d4b6f..bb588327f0f2 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -5073,7 +5073,7 @@ describe("agent browser access", () => { const issued = yield* startSessionWith(false, threadId); - assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests"] }]); + assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests", "worktree"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5084,7 +5084,7 @@ describe("agent browser access", () => { const issued = yield* startSessionWith(true, threadId); assert.deepEqual(issued, [ - { threadId, capabilities: ["device", "preview", "pull-requests"] }, + { threadId, capabilities: ["device", "preview", "pull-requests", "worktree"] }, ]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5095,7 +5095,9 @@ describe("agent browser access", () => { const issued = yield* startSessionWith({ browser: false, device: true }, threadId); - assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); + assert.deepEqual(issued, [ + { threadId, capabilities: ["device", "pull-requests", "worktree"] }, + ]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5103,7 +5105,7 @@ describe("agent browser access", () => { Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-off"); const issued = yield* startSessionWith({ browser: true, device: false }, threadId, false); - assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests"] }]); + assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests", "worktree"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5111,7 +5113,9 @@ describe("agent browser access", () => { Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-off-device-on"); const issued = yield* startSessionWith(true, threadId, false); - assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); + assert.deepEqual(issued, [ + { threadId, capabilities: ["device", "pull-requests", "worktree"] }, + ]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5119,7 +5123,9 @@ describe("agent browser access", () => { Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-on"); const issued = yield* startSessionWith({ browser: false, device: false }, threadId, true); - assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); + assert.deepEqual(issued, [ + { threadId, capabilities: ["preview", "pull-requests", "worktree"] }, + ]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5129,7 +5135,9 @@ describe("agent browser access", () => { const issued = yield* startSessionWith({ browser: false, device: false }, threadId, { device: true, }); - assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); + assert.deepEqual(issued, [ + { threadId, capabilities: ["device", "pull-requests", "worktree"] }, + ]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5144,7 +5152,9 @@ describe("agent browser access", () => { { device: false }, { withoutOrchestration: true }, ); - assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); + assert.deepEqual(issued, [ + { threadId, capabilities: ["preview", "pull-requests", "worktree"] }, + ]); }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index cdac979c4dfd..21bc0d16ed09 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -906,7 +906,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const agentAccessCapabilities = Effect.fn("ProviderService.agentAccessCapabilities")(function* ( threadId: ThreadId, ) { - const capabilities = new Set(["pull-requests"]); + const capabilities = new Set(["pull-requests", "worktree"]); const access = yield* agentAccessSettings(threadId); if (access.browser) capabilities.add("preview"); if (access.device) capabilities.add("device"); diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index e73c50adfd6d..abdc628bbe4c 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -10,6 +10,12 @@ describe("buildRuntimeInstructions", () => { expect(instructions).toContain("call list_thread_pull_requests and link any PR"); }); + it("asks agents to hand the thread off to a worktree they create for it", () => { + const instructions = buildRuntimeInstructions({ harness: "Claude Code" }); + expect(instructions).toContain("call t3_worktree_handoff with the worktree's absolute path"); + expect(instructions).toContain("Do not call it for worktrees you create for other purposes"); + }); + it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 5e72586062e5..fbe3adf5a40a 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -1,6 +1,10 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. -`; + + + +When the t3-code MCP server exposes t3_worktree_handoff and you create a git worktree to continue this thread's work in, call t3_worktree_handoff with the worktree's absolute path right after creating it. Until you do, T3 Code shows the thread on the project checkout and opens that folder instead of the worktree. Do not call it for worktrees you create for other purposes. +`; /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ export function buildRuntimeInstructions(runtime: { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 9434e5996614..5ed7f8d1cdef 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -9,6 +9,8 @@ On web and desktop, a new thread keeps the current project and carries your mode and mode selections, unless the destination project has its own model default. Its branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. +An agent that creates a worktree during a thread can move the thread there with +the `t3_worktree_handoff` tool, so the branch toolbar and **Open** follow it. When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it.