From 06e627448bc1ee58487c1a2a9d6bf8d5ef479af3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 22:32:55 -0700 Subject: [PATCH 01/21] feat(server): T3 MCP tools take explicit thread and project targets (#15219) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 10 +- apps/server/src/mcp/McpHttpServer.test.ts | 10 +- .../src/mcp/McpInvocationContext.test.ts | 52 ++- apps/server/src/mcp/McpInvocationContext.ts | 69 ++- .../server/src/mcp/McpSessionRegistry.test.ts | 4 +- apps/server/src/mcp/McpSessionRegistry.ts | 27 +- .../OrchestratorMcpService.activity.test.ts | 166 ++++--- .../src/mcp/OrchestratorMcpService.test.ts | 71 ++- apps/server/src/mcp/OrchestratorMcpService.ts | 411 ++++++++++++------ ...OrchestratorMcpToolkit.integration.test.ts | 57 ++- .../src/mcp/PreviewAutomationBroker.test.ts | 68 +-- .../server/src/mcp/PreviewAutomationBroker.ts | 27 +- .../src/mcp/ThreadMetadataMcpService.test.ts | 98 ++++- .../src/mcp/ThreadMetadataMcpService.ts | 74 +++- .../server/src/mcp/WorktreeMcpService.test.ts | 10 +- apps/server/src/mcp/WorktreeMcpService.ts | 69 +-- apps/server/src/mcp/threadAccess.ts | 155 +++++-- .../src/mcp/toolkits/attachment/handlers.ts | 6 +- .../src/mcp/toolkits/attachment/tools.ts | 2 +- apps/server/src/mcp/toolkits/core.test.ts | 247 ++++++++++- .../src/mcp/toolkits/device/handlers.ts | 12 +- .../src/mcp/toolkits/environment/handlers.ts | 36 +- .../src/mcp/toolkits/orchestrator/handlers.ts | 4 +- .../src/mcp/toolkits/orchestrator/tools.ts | 26 +- .../src/mcp/toolkits/preview/handlers.ts | 6 +- .../toolkits/previewControls/handlers.test.ts | 10 +- .../mcp/toolkits/previewControls/handlers.ts | 8 +- .../src/mcp/toolkits/project/handlers.test.ts | 115 ++++- .../src/mcp/toolkits/project/handlers.ts | 63 ++- apps/server/src/mcp/toolkits/project/tools.ts | 2 +- .../toolkits/pullRequests/handlers.test.ts | 10 +- .../src/mcp/toolkits/pullRequests/handlers.ts | 69 ++- .../src/mcp/toolkits/pullRequests/tools.ts | 33 +- .../src/mcp/toolkits/thread/handlers.ts | 43 +- apps/server/src/mcp/toolkits/thread/tools.ts | 33 +- .../src/mcp/toolkits/worktree/handlers.ts | 11 +- .../server/src/mcp/toolkits/worktree/tools.ts | 8 +- .../ProviderSessionManager.test.ts | 12 +- .../ProviderSessionManager.ts | 4 +- packages/contracts/src/orchestratorMcp.ts | 33 +- packages/contracts/src/previewAutomation.ts | 7 +- packages/contracts/src/threadMetadataMcp.ts | 2 +- packages/contracts/src/worktreeMcp.ts | 1 + 43 files changed, 1657 insertions(+), 524 deletions(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 3a9307314c39..f5aba005f9a8 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -19,9 +19,13 @@ const environmentId = EnvironmentId.make("environment-device-test"); const threadId = ThreadId.make("thread-device-test"); const invocation = (capabilities: ReadonlyArray) => ({ environmentId, - threadId, - providerSessionId: "provider-session-device-test", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-device-test", + thread: { + threadId, + providerSessionId: "provider-session-device-test", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(capabilities), issuedAt: 1, }); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 69801ee06100..96a0de29f3d0 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -27,9 +27,13 @@ const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const invocation = { environmentId, - threadId, - providerSessionId: "provider-session-mcp-test", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-mcp-test", + thread: { + threadId, + providerSessionId: "provider-session-mcp-test", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["preview"] as const), issuedAt: 1, }; diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 4314c82e6ddc..1daa9740f019 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -13,9 +13,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; it.effect("reports the scoped credential context when preview capability is unavailable", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(), issuedAt: 1, }; @@ -30,9 +34,9 @@ it.effect("reports the scoped credential context when preview capability is unav expect(error).toMatchObject({ capability: "preview", environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, + threadId: invocation.thread?.threadId, + providerSessionId: invocation.thread?.providerSessionId, + providerInstanceId: invocation.thread?.providerInstanceId, }); expect(error.message).toContain("MCP credential does not grant the preview capability"); expect(error.message).toContain("use a headless browser from the shell"); @@ -42,9 +46,13 @@ it.effect("reports the scoped credential context when preview capability is unav it.effect("reports other missing capabilities with the neutral error", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["preview"]), issuedAt: 1, }; @@ -56,7 +64,10 @@ it.effect("reports other missing capabilities with the neutral error", () => { ); expect(error).toBeInstanceOf(McpCapabilityUnavailableError); - expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId }); + expect(error).toMatchObject({ + capability: "pull-requests", + threadId: invocation.thread?.threadId, + }); const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -64,3 +75,24 @@ it.effect("reports other missing capabilities with the neutral error", () => { expect(scope).toBe(invocation); }); }); + +it.effect("refuses thread-owned capabilities to a caller signed in from outside a thread", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + requestNamespace: "client:session-1", + thread: undefined, + client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" }, + capabilities: new Set(["preview", "orchestration"]), + issuedAt: 1, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireThreadMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toMatchObject({ capability: "preview", environmentId: "environment-1" }); + expect(error.threadId).toBeUndefined(); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index eff1d01cba29..01906c1b44f6 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,8 +1,10 @@ import { type EnvironmentId, McpCapabilityUnavailableError, + OrchestratorMcpFailure, PreviewAutomationUnavailableError, type ProviderInstanceId, + type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -17,13 +19,35 @@ const ALL_MCP_CAPABILITIES = [ ] as const; export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; -export interface McpInvocationScope { - readonly environmentId: EnvironmentId; +/** A provider session T3 Code launched for one thread. */ +export interface McpThreadCaller { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; +} + +/** An agent T3 Code did not launch, signed in through MCP OAuth. */ +export interface McpClientCaller { + readonly sessionId: string; + readonly label: string; + readonly runtimeModeCeiling: RuntimeMode; +} + +/** + * Who is calling and what they may do. Tool parameters choose the target + * (thread, project); the caller sets the limits. A thread caller's omitted + * target falls back to its own thread; a client caller has no own thread, so + * tools that act as the caller (delegate_task, preview, worktree handoff) + * need `thread`. + */ +export interface McpInvocationScope { + readonly environmentId: EnvironmentId; readonly capabilities: ReadonlySet; readonly issuedAt: number; + /** Namespaces idempotency keys so two callers reusing a clientRequestId cannot collide. */ + readonly requestNamespace: string; + readonly thread: McpThreadCaller | undefined; + readonly client: McpClientCaller | undefined; } export class McpInvocationContext extends Context.Service< @@ -42,9 +66,13 @@ const missingCapability = ( ): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => { const fields = { environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, + ...(invocation.thread === undefined + ? {} + : { + threadId: invocation.thread.threadId, + providerSessionId: invocation.thread.providerSessionId, + providerInstanceId: invocation.thread.providerInstanceId, + }), }; return capability === "preview" ? new PreviewAutomationUnavailableError({ capability, ...fields }) @@ -62,3 +90,34 @@ export const requireMcpCapability = ( ), Effect.withSpan("mcp.requireCapability"), ); + +/** + * Preview tabs and device sessions belong to the calling thread, so their + * capabilities are only ever granted to thread callers. A scope that carries + * one without a thread is refused the same way as a missing capability. + */ +export const requireThreadMcpCapability = ( + capability: C, +): Effect.Effect, McpInvocationContext> => + McpInvocationContext.pipe( + Effect.filterOrFail( + (invocation): invocation is McpThreadInvocationScope => + invocation.capabilities.has(capability) && invocation.thread !== undefined, + (invocation) => missingCapability(invocation, capability) as McpCapabilityError, + ), + Effect.withSpan("mcp.requireCapability"), + ); + +const threadCallerRequired = (operation: string) => + new OrchestratorMcpFailure({ + code: "thread_credential_required", + message: `${operation} acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.`, + }); + +/** A scope with a thread caller, for tools whose whole surface acts as the caller. */ +export type McpThreadInvocationScope = McpInvocationScope & { readonly thread: McpThreadCaller }; + +export const requireThreadScope = (scope: McpInvocationScope, operation: string) => + scope.thread === undefined + ? Effect.fail(threadCallerRequired(operation)) + : Effect.succeed(scope as McpThreadInvocationScope); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index ba3c20968d10..719bed1ed4e7 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -47,7 +47,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t expect(token.length).toBeGreaterThan(20); const resolved = yield* registry.resolve(token); - expect(resolved?.threadId).toBe(threadId); + expect(resolved?.thread.threadId).toBe(threadId); expect(resolved?.capabilities).toEqual( new Set(["preview", "orchestration", "worktree", "pull-requests"]), ); @@ -158,7 +158,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", yield* registry.touch(threadId); } - expect((yield* registry.resolve(token))?.threadId).toBe(threadId); + expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId); }), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index c62c3ca5043c..10b933d4f45b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -32,7 +32,7 @@ export interface McpSessionRegistryShape { readonly issue: (request: McpCredentialRequest) => Effect.Effect; readonly resolve: ( rawToken: string, - ) => Effect.Effect; + ) => Effect.Effect; /** * Records a sign of life for every credential bound to `threadId`. Provider * turns call this so that a session which is plainly alive keeps its @@ -49,9 +49,10 @@ export class McpSessionRegistry extends Context.Service< McpSessionRegistryShape >()("t3/mcp/McpSessionRegistry") {} +/** Registry credentials always belong to a provider session, so their scope has a thread. */ interface CredentialRecord { readonly tokenHash: string; - readonly scope: McpInvocationContext.McpInvocationScope; + readonly scope: McpInvocationContext.McpThreadInvocationScope; readonly lastAliveAt: number; } @@ -127,11 +128,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); const tokenHash = yield* hashToken(rawToken); const browserToolsAvailable = request.browserToolsAvailable ?? true; - const scope: McpInvocationContext.McpInvocationScope = { + const scope: McpInvocationContext.McpThreadInvocationScope = { environmentId, - threadId: ThreadId.make(request.threadId), - providerSessionId, - providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + requestNamespace: providerSessionId, + thread: { + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + }, + client: undefined, capabilities: new Set([ "orchestration", "worktree", @@ -148,9 +153,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( return { config: { environmentId, - threadId: scope.threadId, + threadId: scope.thread.threadId, providerSessionId, - providerInstanceId: scope.providerInstanceId, + providerInstanceId: scope.thread.providerInstanceId, endpoint, authorizationHeader: `Bearer ${rawToken}`, browserToolsAvailable: scope.capabilities.has("preview"), @@ -183,7 +188,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const current = pruneDead(records, timestamp); const next = new Map(current); for (const [tokenHash, record] of current) { - if (record.scope.threadId === threadId) { + if (record.scope.thread.threadId === threadId) { next.set(tokenHash, { ...record, lastAliveAt: timestamp }); } } @@ -203,11 +208,11 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( touch, revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( function* (providerSessionId) { - yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); + yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId); }, ), revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { - yield* revokeWhere((record) => record.scope.threadId === threadId); + yield* revokeWhere((record) => record.scope.thread.threadId === threadId); }), revokeAll: SynchronizedRef.set(state, { records: new Map() }), }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 06eff1e6f8b1..2433eb8a704a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -2,10 +2,12 @@ import { EnvironmentId, NodeId, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, ProjectId, ProviderDriverKind, ProviderInstanceId, RunId, + ScheduledTaskId, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -16,6 +18,7 @@ import { expect, it } from "vite-plus/test"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -37,9 +40,13 @@ const parentInstanceId = ProviderInstanceId.make("codex"); const makeScope = (): McpInvocationContext.McpInvocationScope => ({ environmentId, - threadId: parentThreadId, - providerSessionId: "provider-session-mcp-orchestrator-detail", - providerInstanceId: parentInstanceId, + requestNamespace: "provider-session-mcp-orchestrator-detail", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session-mcp-orchestrator-detail", + providerInstanceId: parentInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }); @@ -127,10 +134,21 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a threadId === parentThreadId ? Effect.succeed(projection) : Effect.die(`unexpected thread ${threadId}`), + getThreadShell: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? (projection.thread as unknown as OrchestrationV2ThreadShell) + : null, + ), + getProjectThreadRecords: (input) => + input.threadId === parentThreadId + ? Effect.succeed(projection) + : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -180,10 +198,21 @@ it("readThread prefers waiting activity status over a newer cancelled queued run threadId === parentThreadId ? Effect.succeed(projection) : Effect.die(`unexpected thread ${threadId}`), + getThreadShell: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? (projection.thread as unknown as OrchestrationV2ThreadShell) + : null, + ), + getProjectThreadRecords: (input) => + input.threadId === parentThreadId + ? Effect.succeed(projection) + : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -295,6 +324,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -317,34 +347,12 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy }).pipe(Effect.provide(layer), Effect.runPromise); }); -it("readThread reaches a thread the user attached as context, but not one an agent attached", async () => { +it("readThread and sendToThread reach threads in other projects", async () => { + let parentRuns: ReadonlyArray = [ + makeRun({ id: RunId.make("run-parent-live"), ordinal: 1, status: "running" }), + ]; const foreignProjectId = ProjectId.make("project-mcp-orchestrator-foreign"); const foreignThreadId = ThreadId.make("thread-mcp-orchestrator-foreign"); - const agentOnlyThreadId = ThreadId.make("thread-mcp-orchestrator-agent-only"); - const threadRecord = (threadId: ThreadId) => ({ - version: 1, - kind: "thread", - contextId: `thread_${threadId}`, - label: "Attached", - environmentId, - threadId, - title: "Attached", - }); - const message = (input: { createdBy: "user" | "agent"; threadId: ThreadId }) => ({ - id: `message-${input.threadId}`, - threadId: parentThreadId, - runId: null, - nodeId: null, - role: "user", - text: `[Attached](t3-context://v1/thread/thread_${input.threadId})`, - context: { version: 1, records: [threadRecord(input.threadId)] }, - attachments: [], - streaming: false, - createdBy: input.createdBy, - creationSource: input.createdBy === "user" ? "user" : "mcp", - createdAt: now, - updatedAt: now, - }); const parentProjection = { thread: baseThread({ threadId: parentThreadId, @@ -352,13 +360,12 @@ it("readThread reaches a thread the user attached as context, but not one an age instanceId: parentInstanceId, model: "gpt-5.4", }), - runs: [], + get runs() { + return parentRuns; + }, visibleTurnItems: [], runtimeRequests: [], - messages: [ - message({ createdBy: "user", threadId: foreignThreadId }), - message({ createdBy: "agent", threadId: agentOnlyThreadId }), - ], + messages: [], contextTransfers: [], subagents: [], updatedAt: now, @@ -406,11 +413,15 @@ it("readThread reaches a thread the user attached as context, but not one an age Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => { if (threadId === parentThreadId) return Effect.succeed(parentProjection); - if (threadId === foreignThreadId || threadId === agentOnlyThreadId) { - return Effect.succeed(foreignProjection(threadId)); - } + if (threadId === foreignThreadId) return Effect.succeed(foreignProjection(threadId)); return Effect.die(`unexpected thread ${threadId}`); }, + getThreadShell: (threadId) => + Effect.succeed( + threadId === foreignThreadId + ? (foreignProjection(threadId).thread as unknown as OrchestrationV2ThreadShell) + : null, + ), getTimelinePage: (threadId) => Effect.succeed({ items: foreignProjection(threadId).visibleTurnItems, @@ -418,18 +429,36 @@ it("readThread reaches a thread the user attached as context, but not one an age hasMore: false, }), getProjectThreadRecords: (input) => - Effect.fail( - new ThreadManagementService.ThreadManagementThreadNotFoundError({ - projectId: input.projectId, - threadId: input.threadId, - }), - ), + input.threadId === foreignThreadId && input.projectId === foreignProjectId + ? Effect.succeed(foreignProjection(input.threadId)) + : Effect.fail( + new ThreadManagementService.ThreadManagementThreadNotFoundError({ + projectId: input.projectId, + threadId: input.threadId, + }), + ), + sendToThread: () => + Effect.succeed({ + run: { id: "run-foreign", status: "queued" }, + delivery: "started", + } as unknown as ThreadManagementService.ThreadManagementSendResult), } satisfies Partial), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ - list: () => Effect.succeed({ tasks: [] }), + list: () => + Effect.succeed({ + tasks: [ + { + id: "task-foreign", + projectId: foreignProjectId, + runtimeMode: "approval-required", + interactionMode: "default", + } as never, + ], + }), } satisfies Partial), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -441,18 +470,47 @@ it("readThread reaches a thread the user attached as context, but not one an age await Effect.gen(function* () { const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const attached = yield* service.readThread(makeScope(), { threadId: foreignThreadId }); - expect(attached.thread.threadId).toBe(foreignThreadId); - expect(attached.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]); + const foreign = yield* service.readThread(makeScope(), { threadId: foreignThreadId }); + expect(foreign.thread.threadId).toBe(foreignThreadId); + expect(foreign.thread.projectId).toBe(foreignProjectId); + expect(foreign.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]); - const denied = yield* service - .readThread(makeScope(), { threadId: agentOnlyThreadId }) - .pipe(Effect.flip); - expect(denied.code).toBe("thread_not_found"); + const sent = yield* service.sendToThread(makeScope(), { + threadId: foreignThreadId, + message: "hi", + }); + expect(sent.threadId).toBe(foreignThreadId); - const write = yield* service - .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi" }) + // Once the caller's run ends, it can still read other threads but no longer write to them. + parentRuns = []; + yield* service.readThread(makeScope(), { threadId: foreignThreadId }); + const stale = yield* service + .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi again" }) + .pipe(Effect.flip); + expect(stale.code).toBe("parent_not_active"); + const staleInterrupt = yield* service + .interruptThread(makeScope(), { threadId: foreignThreadId }) + .pipe(Effect.flip); + expect(staleInterrupt.code).toBe("parent_not_active"); + // Nor create, change or remove scheduled work in another project. + const staleSchedule = yield* service + .scheduleTask(makeScope(), { + projectId: foreignProjectId, + prompt: "check in later", + schedule: { type: "interval", everyMs: 3_600_000 }, + }) + .pipe(Effect.flip); + expect(staleSchedule.code).toBe("parent_not_active"); + const staleUpdate = yield* service + .updateScheduledTask(makeScope(), { + scheduledTaskId: ScheduledTaskId.make("task-foreign"), + enabled: false, + }) + .pipe(Effect.flip); + expect(staleUpdate.code).toBe("parent_not_active"); + const staleDelete = yield* service + .deleteScheduledTask(makeScope(), { scheduledTaskId: ScheduledTaskId.make("task-foreign") }) .pipe(Effect.flip); - expect(write.code).toBe("thread_not_found"); + expect(staleDelete.code).toBe("parent_not_active"); }).pipe(Effect.provide(layer), Effect.runPromise); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index eae4ffa8c0b1..5d8025c5fe12 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -22,6 +22,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -118,13 +119,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-ack"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-ack", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-ack", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-ack", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -202,13 +208,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-restart"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-restart", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-restart", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-restart", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -278,13 +289,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-cancel", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-cancel", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -346,13 +362,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel-failed"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-cancel-failed", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-cancel-failed", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-failed", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -421,13 +442,18 @@ describe("OrchestratorMcpService", () => { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), }), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-cancel-dispose-failed", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:mcp-cancel-dispose-failed", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-dispose-failed", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -460,9 +486,13 @@ describe("OrchestratorMcpService provider resolution", () => { const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-providers"), - threadId: parentThreadId, - providerSessionId: "provider-session:mcp-providers", - providerInstanceId: codexInstanceId, + requestNamespace: "provider-session:mcp-providers", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-providers", + providerInstanceId: codexInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -613,6 +643,7 @@ describe("OrchestratorMcpService provider resolution", () => { antigravityInstanceId, disabledAntigravityInstanceId, ]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -756,6 +787,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -849,6 +881,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -897,6 +930,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1060,6 +1094,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 83a7a4cbfde6..097ce0356264 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -29,6 +29,8 @@ import { type OrchestratorMcpTaskCancelInput, type OrchestratorMcpTaskCancelResult, type OrchestratorMcpUpdateScheduledTaskInput, + type OrchestratorMcpListScheduledTasksInput, + type ProjectId, type OrchestratorMcpThreadDetail, type OrchestratorMcpThreadInterruptInput, type OrchestratorMcpThreadInterruptResult, @@ -68,9 +70,14 @@ import { delegatedTaskProgress, } from "../orchestration-v2/SubagentProjection.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; -import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { + type McpInvocationScope, + type McpThreadInvocationScope, + requireThreadScope, +} from "./McpInvocationContext.ts"; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; @@ -115,6 +122,7 @@ export interface OrchestratorMcpServiceShape { ) => Effect.Effect; readonly listScheduledTasks: ( scope: McpInvocationScope, + input: OrchestratorMcpListScheduledTasksInput, ) => Effect.Effect; readonly updateScheduledTask: ( scope: McpInvocationScope, @@ -486,7 +494,7 @@ function stableCommandId(input: { [ "command", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.operation), stablePart(input.requestKey), ...(input.index === undefined ? [] : [String(input.index)]), @@ -503,7 +511,7 @@ function stableThreadId(input: { [ "thread", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.requestKey), String(input.index), ].join(":"), @@ -519,7 +527,7 @@ function stableMessageId(input: { [ "message", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.requestKey), String(input.index), ].join(":"), @@ -535,7 +543,7 @@ function stableOperationMessageId(input: { [ "message", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), stablePart(input.operation), stablePart(input.requestKey), ].join(":"), @@ -760,6 +768,35 @@ const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; + const projects = yield* ProjectService.ProjectService; + + /** A caller-named project, which must exist before anything is recorded against it. */ + const requireProject = (projectId: ProjectId) => + projects.getById(projectId).pipe( + Effect.mapError((error) => + failure("orchestration_error", `Unable to read project ${projectId}: ${error.message}`), + ), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(failure("invalid_request", `Project ${projectId} was not found.`)), + onSome: Effect.succeed, + }), + ), + ); + + /** A client has no thread to inherit a model from, so it falls back to the project default. */ + const projectDefaultModelSelection = ( + project: Effect.Success>, + ) => + project.defaultModelSelection === null + ? Effect.fail( + failure( + "invalid_request", + `Project ${project.id} has no default model, so this caller cannot pick one for it.`, + ), + ) + : Effect.succeed(project.defaultModelSelection); const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("orchestration") @@ -817,61 +854,133 @@ const make = Effect.gen(function* () { ) .pipe(Effect.mapError(threadManagementFailure)); - const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) => + /** + * The caller's own thread and the most it may hand to threads it targets. A + * thread caller is capped by its own modes; an OAuth client by the ceiling + * chosen when it was approved. + */ + const loadCaller = (scope: McpInvocationScope) => Effect.gen(function* () { yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + if (scope.thread === undefined) { + return { + parent: undefined, + limits: { + runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", + interactionMode: "default", + } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, + } as const; + } + const parent = yield* loadProjection(scope.thread.threadId); + return { + parent, + limits: { + runtimeMode: parent.thread.runtimeMode, + interactionMode: parent.thread.interactionMode, + }, + } as const; + }); + + /** The caller's own thread, for operations that act as the caller. */ + const loadThreadCaller = (scope: McpInvocationScope, operation: string) => + Effect.gen(function* () { + yield* requireCapability(scope); + const threadScope = yield* requireThreadScope(scope, operation); + const parent = yield* loadProjection(threadScope.thread.threadId); + return { scope: threadScope, parent } as const; + }); + + /** A target project: the one passed, else the calling thread's. */ + const resolveProjectTarget = ( + parent: Pick | undefined, + projectId: ProjectId | undefined, + ) => + projectId !== undefined + ? Effect.succeed(projectId) + : parent !== undefined + ? Effect.succeed(parent.thread.projectId) + : Effect.fail( + failure( + "target_required", + "Pass projectId: this MCP client is not running inside a T3 thread.", + ), + ); + + /** Any live thread in the environment. */ + const loadTargetThread = (threadId: ThreadId) => + Effect.gen(function* () { + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + } + return yield* loadProjectThread(shell.projectId, threadId); + }); + + const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) => + Effect.gen(function* () { + const caller = yield* loadCaller(scope); const target = - threadId === scope.threadId - ? parent - : yield* loadProjectThread(parent.thread.projectId, threadId); - return { parent, target } as const; + caller.parent !== undefined && threadId === caller.parent.thread.id + ? caller.parent + : yield* loadTargetThread(threadId); + return { ...caller, target } as const; }); /** - * A thread the user attached as context (a `thread` record on one of their own messages) - * is readable even outside the calling project. Only records the user authored count: - * an agent cannot widen its own reach by writing a record. + * A thread caller writes to another thread only while its own run is live, + * so a credential that outlived its session cannot reach across threads. */ - const userAttachedThreadIds = ( - parent: Pick, - ): Set => { - const ids = new Set(); - for (const message of parent.messages) { - if (message.role !== "user" || message.createdBy !== "user") continue; - for (const record of message.context?.records ?? []) { - if (record.kind === "thread" && "threadId" in record) ids.add(record.threadId); - } - } - return ids; + const assertLiveCallerForOtherThread = ( + scope: McpInvocationScope, + parent: Pick | undefined, + target: Pick, + ) => + parent === undefined || target.thread.id === parent.thread.id + ? Effect.void + : assertLiveCaller(scope, parent); + + /** Scheduled work outside the caller's own project needs the same live run. */ + const assertLiveCallerForOtherProject = ( + scope: McpInvocationScope, + parent: Pick | undefined, + projectId: ProjectId, + ) => + parent === undefined || projectId === parent.thread.projectId + ? Effect.void + : assertLiveCaller(scope, parent); + + const assertLiveCaller = ( + scope: McpInvocationScope, + parent: Pick, + ) => { + const activeRun = ThreadManagementService.latestActiveRun(parent); + return parent.thread.archivedAt !== null || + activeRun === undefined || + activeRun.providerInstanceId !== scope.thread?.providerInstanceId + ? Effect.fail( + failure("parent_not_active", "The calling provider no longer owns an active thread run."), + ) + : Effect.void; }; const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const loadTarget = () => - threadManagement - .getThreadRecords(threadId, ["runs", "runtimeRequests", "contextTransfers"]) - .pipe(Effect.mapError(threadManagementFailure)); - if (threadId === scope.threadId) return { parent, target: yield* loadTarget() } as const; + const { parent } = yield* loadCaller(scope); + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); + } const target = yield* threadManagement - .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, [ + .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [ "runs", "runtimeRequests", "contextTransfers", ]) - .pipe( - Effect.mapError(threadManagementFailure), - Effect.catchIf( - (error) => - error.code === "thread_not_found" && userAttachedThreadIds(parent).has(threadId), - loadTarget, - ), - ); - if (target.thread.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); - } + .pipe(Effect.mapError(threadManagementFailure)); return { parent, target } as const; }); @@ -1010,7 +1119,7 @@ const make = Effect.gen(function* () { : Effect.succeed(clientRequestId); const readTask = ( - scope: McpInvocationScope, + scope: McpThreadInvocationScope, taskId: NodeId, waitTimedOut = false, acknowledgeTerminal = false, @@ -1018,17 +1127,17 @@ const make = Effect.gen(function* () { ): Effect.Effect => Effect.gen(function* () { yield* requireCapability(scope); - const parentProjection = yield* loadProjection(scope.threadId); + const parentProjection = yield* loadProjection(scope.thread.threadId); const task = parentProjection.subagents.find( (candidate) => candidate.id === taskId && candidate.origin === "app_owned" && - candidate.threadId === scope.threadId, + candidate.threadId === scope.thread.threadId, ); if (task === undefined || task.childThreadId === null) { return yield* failure( "task_not_found", - `Delegated task ${taskId} does not belong to thread ${scope.threadId}.`, + `Delegated task ${taskId} does not belong to thread ${scope.thread.threadId}.`, ); } const childControls = yield* threadManagement @@ -1094,7 +1203,7 @@ const make = Effect.gen(function* () { (transfer) => transfer.type === "subagent_result" && transfer.sourceThreadId === task.childThreadId && - transfer.targetThreadId === scope.threadId, + transfer.targetThreadId === scope.thread.threadId, ); const resultTransferForRun = (run: OrchestrationV2Run | undefined) => !canExposeTaskRunResult(run) @@ -1145,10 +1254,10 @@ const make = Effect.gen(function* () { requestKey: acknowledgementRequestKey, operation: acknowledgementOperation, }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, taskId, observedByRunId: - observingRun?.providerInstanceId === scope.providerInstanceId + observingRun?.providerInstanceId === scope.thread.providerInstanceId ? observingRun.id : null, }) @@ -1164,7 +1273,7 @@ const make = Effect.gen(function* () { return response; }); - const waitForTask = (scope: McpInvocationScope, taskId: NodeId, timeoutMs: number) => + const waitForTask = (scope: McpThreadInvocationScope, taskId: NodeId, timeoutMs: number) => Effect.gen(function* () { while (true) { const result = yield* readTask(scope, taskId, false, true); @@ -1173,11 +1282,17 @@ const make = Effect.gen(function* () { } }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs))); - // Load a single scheduled task and enforce that it belongs to the calling - // thread's project, so agents can only read/mutate tasks in their own scope. - const loadScopedScheduledTask = ( - projectId: ScheduledTask["projectId"], + /** + * A scheduled task the caller may change: one whose modes are no broader + * than the caller's own, so editing its prompt cannot run work above the + * caller's limits. + */ + const loadScheduledTask = ( scheduledTaskId: ScheduledTask["id"], + limits: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, ): Effect.Effect => Effect.gen(function* () { const { tasks } = yield* scheduledTasks @@ -1188,21 +1303,38 @@ const make = Effect.gen(function* () { ), ); const task = tasks.find((candidate) => candidate.id === scheduledTaskId); - if (task === undefined || task.projectId !== projectId) { - return yield* failure( - "task_not_found", - `Scheduled task ${scheduledTaskId} was not found in the calling project.`, - ); + if (task === undefined) { + return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`); } + yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, task.interactionMode); return task; }); return OrchestratorMcpService.of({ scheduleTask: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const bindToCurrentThread = input.bindToCurrentThread ?? true; + const { parent, limits } = yield* loadCaller(scope); + const projectId = yield* resolveProjectTarget(parent, input.projectId); + yield* assertLiveCallerForOtherProject(scope, parent, projectId); + const project = yield* requireProject(projectId); + // Binding means "wake this thread", which only a thread caller in that project has. + const bindToCurrentThread = + input.bindToCurrentThread ?? + (parent !== undefined && parent.thread.projectId === projectId); + if ( + bindToCurrentThread && + (parent === undefined || parent.thread.projectId !== projectId) + ) { + return yield* failure( + "invalid_request", + parent === undefined + ? "bindToCurrentThread needs an agent running inside a T3 thread." + : "bindToCurrentThread binds to this thread, which belongs to a different project.", + ); + } + const modelSelection = + parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project)); const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? ""; const title = input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task"); @@ -1211,12 +1343,12 @@ const make = Effect.gen(function* () { prompt: input.prompt, enabled: input.enabled ?? true, schedule: input.schedule, - projectId: parent.thread.projectId, - threadId: bindToCurrentThread ? scope.threadId : null, + projectId, + threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null, workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread), - modelSelection: parent.thread.modelSelection, - runtimeMode: parent.thread.runtimeMode, - interactionMode: parent.thread.interactionMode, + modelSelection, + runtimeMode: limits.runtimeMode, + interactionMode: limits.interactionMode, createdBy: "agent", creationSource: "mcp", // Scope the idempotency key by provider session so two callers @@ -1240,10 +1372,10 @@ const make = Effect.gen(function* () { ); return scheduledTaskSummary(task); }), - listScheduledTasks: (scope) => + listScheduledTasks: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { parent } = yield* loadCaller(scope); + const projectId = input.projectId ?? parent?.thread.projectId; const { tasks } = yield* scheduledTasks .list() .pipe( @@ -1251,26 +1383,33 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not list scheduled tasks: ${error.message}`), ), ); - // Only expose tasks belonging to the calling thread's project. return { tasks: tasks - .filter((task) => task.projectId === parent.thread.projectId) + .filter((task) => projectId === undefined || task.projectId === projectId) .map(scheduledTaskSummary), }; }), updateScheduledTask: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const existing = yield* loadScopedScheduledTask( - parent.thread.projectId, - input.scheduledTaskId, - ); + const { parent, limits } = yield* loadCaller(scope); + const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); + yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); + if ( + input.bindToCurrentThread === true && + (parent === undefined || parent.thread.projectId !== existing.projectId) + ) { + return yield* failure( + "invalid_request", + parent === undefined + ? "bindToCurrentThread needs an agent running inside a T3 thread." + : "bindToCurrentThread binds to this thread, which belongs to a different project.", + ); + } const threadId = input.bindToCurrentThread === undefined ? existing.threadId - : input.bindToCurrentThread - ? scope.threadId + : input.bindToCurrentThread && parent !== undefined + ? parent.thread.id : null; // Rebinding changes where runs execute, so the workspace strategy must // follow: unbinding a root-strategy task would otherwise run loose @@ -1305,12 +1444,9 @@ const make = Effect.gen(function* () { }), deleteScheduledTask: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); - const existing = yield* loadScopedScheduledTask( - parent.thread.projectId, - input.scheduledTaskId, - ); + const { parent, limits } = yield* loadCaller(scope); + const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); + yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); yield* scheduledTasks .delete({ id: existing.id }) .pipe( @@ -1322,16 +1458,15 @@ const make = Effect.gen(function* () { }), capabilities: (scope) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); return { - parentThreadId: scope.threadId, - inheritedProviderInstanceId: parent.thread.modelSelection.instanceId, - inheritedModel: parent.thread.modelSelection.model, - runtimeMode: parent.thread.runtimeMode, - interactionMode: parent.thread.interactionMode, + parentThreadId: parent?.thread.id ?? null, + inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, + inheritedModel: parent?.thread.modelSelection.model ?? null, + runtimeMode: limits.runtimeMode, + interactionMode: limits.interactionMode, providers: providers.map((provider) => { const constraints = providerConstraints( provider, @@ -1366,17 +1501,16 @@ const make = Effect.gen(function* () { }, }; }), - delegateTask: (scope, input) => + delegateTask: (callerScope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task"); const parentRun = parent.runs .filter(ThreadManagementService.isActiveRun) .toSorted((left, right) => right.ordinal - left.ordinal)[0]; if ( parentRun === undefined || parentRun.rootNodeId === null || - parentRun.providerInstanceId !== scope.providerInstanceId + parentRun.providerInstanceId !== scope.thread.providerInstanceId ) { return yield* failure( "parent_not_active", @@ -1406,7 +1540,7 @@ const make = Effect.gen(function* () { createdBy: "agent", creationSource: "mcp", commandId, - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, parentRunId: parentRun.id, parentNodeId: parentRun.rootNodeId, task: taskPrompt(input), @@ -1462,7 +1596,7 @@ const make = Effect.gen(function* () { requestKey: key, operation: "delegate-task-wake-policy", }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, taskId, completionWake: "always", }) @@ -1491,12 +1625,17 @@ const make = Effect.gen(function* () { ); return yield* readTask(scope, taskId, true, true); }), - taskStatus: (scope, taskId) => readTask(scope, taskId, false, true), - cancelTask: (scope, input) => + taskStatus: (callerScope, taskId) => Effect.gen(function* () { + const scope = yield* requireThreadScope(callerScope, "task_status"); + return yield* readTask(scope, taskId, false, true); + }), + cancelTask: (callerScope, input) => + Effect.gen(function* () { + const scope = yield* requireThreadScope(callerScope, "task_cancel"); const current = yield* readTask(scope, input.taskId); const key = yield* requestKey(input.clientRequestId); - const parentProjection = yield* loadProjection(scope.threadId); + const parentProjection = yield* loadProjection(scope.thread.threadId); const parentTask = parentProjection.subagents.find( (task) => task.id === input.taskId && task.origin === "app_owned", ); @@ -1511,7 +1650,7 @@ const make = Effect.gen(function* () { requestKey: key, operation: "cancel-task-completion-delivery", }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, taskId: input.taskId, }) .pipe( @@ -1573,15 +1712,14 @@ const make = Effect.gen(function* () { status: "cancel_requested", }; }), - createThreads: (scope, input) => + createThreads: (callerScope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { scope, parent } = yield* loadThreadCaller(callerScope, "create_threads"); const parentRun = ThreadManagementService.latestActiveRun(parent); if ( parentRun === undefined || parentRun.rootNodeId === null || - parentRun.providerInstanceId !== scope.providerInstanceId + parentRun.providerInstanceId !== scope.thread.providerInstanceId ) { return yield* failure( "parent_not_active", @@ -1660,7 +1798,7 @@ const make = Effect.gen(function* () { index, }), threadId, - senderThreadId: scope.threadId, + senderThreadId: scope.thread.threadId, messageId: stableMessageId({ scope, requestKey: key, @@ -1691,7 +1829,7 @@ const make = Effect.gen(function* () { operation: "record-created-thread", index, }), - parentThreadId: scope.threadId, + parentThreadId: scope.thread.threadId, parentRunId: parentRun.id, parentNodeId, targetThreadId: threadId, @@ -1722,11 +1860,11 @@ const make = Effect.gen(function* () { }), listThreads: (scope, input) => Effect.gen(function* () { - yield* requireCapability(scope); - const parent = yield* loadProjection(scope.threadId); + const { parent } = yield* loadCaller(scope); + const projectId = yield* resolveProjectTarget(parent, input.projectId); const projectThreads = yield* threadManagement .listProjectThreads({ - projectId: parent.thread.projectId, + projectId, includeSubagents: input.includeSubagents !== false, }) .pipe( @@ -1755,8 +1893,8 @@ const make = Effect.gen(function* () { const page = filtered.slice(cursor, cursor + limit); const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null; return { - projectId: parent.thread.projectId, - currentThreadId: scope.threadId, + projectId, + currentThreadId: parent?.thread.id ?? null, threads: page.map(listItemFromShell), nextCursor, total: filtered.length, @@ -1795,8 +1933,13 @@ const make = Effect.gen(function* () { { concurrency: 1 }, ); const messagesByThreadId = new Map(sourceMessages); - const task = directAppOwnedChildTask(parent, target); - if (task !== undefined && (input.textOffset ?? 0) === 0) { + const task = parent === undefined ? undefined : directAppOwnedChildTask(parent, target); + if ( + parent !== undefined && + scope.thread !== undefined && + task !== undefined && + (input.textOffset ?? 0) === 0 + ) { const transfer = parent.contextTransfers.find( (transfer) => transfer.type === "subagent_result" && @@ -1822,7 +1965,13 @@ const make = Effect.gen(function* () { maxChars, }) ) { - yield* readTask(scope, task.id, false, true, "thread-read-acknowledge"); + yield* readTask( + scope as McpThreadInvocationScope, + task.id, + false, + true, + "thread-read-acknowledge", + ); } } return { @@ -1845,9 +1994,10 @@ const make = Effect.gen(function* () { }), sendToThread: (scope, input) => Effect.gen(function* () { - const { parent, target } = yield* loadScopedThread(scope, input.threadId); - yield* resolveRuntimeMode(parent.thread.runtimeMode, target.thread.runtimeMode); - yield* resolveInteractionMode(parent.thread.interactionMode, target.thread.interactionMode); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + yield* assertLiveCallerForOtherThread(scope, parent, target); + yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); const mode = input.mode ?? "auto"; const key = yield* requestKey(input.clientRequestId); @@ -1858,14 +2008,14 @@ const make = Effect.gen(function* () { }); const result = yield* threadManagement .sendToThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, commandId: stableCommandId({ scope, requestKey: key, operation: "thread-send", }), threadId: input.threadId, - senderThreadId: scope.threadId, + ...(parent === undefined ? {} : { senderThreadId: parent.thread.id }), messageId, text: input.message, attachments: [], @@ -1893,10 +2043,10 @@ const make = Effect.gen(function* () { }), waitForThread: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadScopedThread(scope, input.threadId); + const { target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, threadId: input.threadId, ...(input.runId === undefined ? {} : { runId: input.runId }), timeoutMs: Math.min( @@ -1914,11 +2064,15 @@ const make = Effect.gen(function* () { }), interruptThread: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadScopedThread(scope, input.threadId); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + yield* assertLiveCallerForOtherThread(scope, parent, target); + // Stopping another thread's work is a write: it must run within the caller's modes. + yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); const key = yield* requestKey(input.clientRequestId); const result = yield* threadManagement .interruptThread({ - projectId: parent.thread.projectId, + projectId: target.thread.projectId, commandId: stableCommandId({ scope, requestKey: key, @@ -1962,4 +2116,5 @@ export const layer: Layer.Layer< | ProviderRegistry.ProviderRegistry | ProviderAdapterRegistry.ProviderAdapterRegistryV2 | ScheduledTaskService.ScheduledTaskService + | ProjectService.ProjectService > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 142e0cc55f12..132b2d3f635f 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -71,6 +71,7 @@ import { materializeReplayTranscriptWorkspace, } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -631,6 +632,16 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(registryLayer), Layer.provide(providerRegistryLayer), Layer.provide(scheduledTaskStubLayer), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed( + id === projectId + ? Option.some({ id, defaultModelSelection: null } as never) + : Option.none(), + ), + }), + ), Layer.provide(NodeServices.layer), ); @@ -676,9 +687,13 @@ describe("orchestrator MCP toolkit", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-orchestrator"), - threadId: parentThreadId, - providerSessionId: "mcp-provider-session-parent", - providerInstanceId: codexInstanceId, + requestNamespace: "mcp-provider-session-parent", + thread: { + threadId: parentThreadId, + providerSessionId: "mcp-provider-session-parent", + providerInstanceId: codexInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -2326,31 +2341,38 @@ describe("orchestrator MCP toolkit", () => { branch: null, worktreePath: cwd, }); + // Targets reach the whole environment; the caller's modes still cap writes. const foreignOrganizeCall = yield* invoke("t3_thread_organize", { threadId: foreignThreadId, action: "pin", }); - expect(foreignOrganizeCall.structuredContent).toMatchObject({ - code: "thread_not_found", - }); - expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).toBeNull(); + expect(foreignOrganizeCall.isError).toBe(false); + expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).not.toBeNull(); const foreignReadCall = yield* invoke("t3_thread_read", { threadId: foreignThreadId, }); expect(foreignReadCall.structuredContent).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "thread_not_found", + thread: { threadId: foreignThreadId, projectId: "project:mcp-foreign" }, }); const foreignUpdateCall = yield* invoke("t3_thread_update", { threadId: foreignThreadId, action: "rename", - title: "Should stay foreign", + title: "Renamed from another project", }); expect(foreignUpdateCall.structuredContent).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "thread_not_found", + threadId: foreignThreadId, + title: "Renamed from another project", + }); + const foreignListCall = yield* invoke("t3_thread_list", { + projectId: "project:mcp-foreign", }); + const foreignListed = yield* decodeThreadListResult( + foreignListCall.structuredContent, + ).pipe(Effect.orDie); + expect(foreignListed.threads.map((thread) => thread.threadId)).toEqual([ + foreignThreadId, + ]); const listCall = yield* invoke("t3_thread_list", { includeSubagents: false, limit: 100, @@ -3548,6 +3570,7 @@ describe("orchestrator MCP toolkit", () => { ), Layer.provide(providerRegistryLayer), Layer.provide(unusedScheduledTaskStubLayer), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(NodeServices.layer), ); @@ -3602,9 +3625,13 @@ describe("orchestrator MCP toolkit", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-replay"), - threadId: parentThreadId, - providerSessionId: "mcp-provider-session-replay-parent", - providerInstanceId: codexInstanceId, + requestNamespace: "mcp-provider-session-replay-parent", + thread: { + threadId: parentThreadId, + providerSessionId: "mcp-provider-session-replay-parent", + providerInstanceId: codexInstanceId, + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index adf55cad5e91..fe08b0875ed1 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -36,9 +36,13 @@ const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices const scope = { environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["preview"] as const), issuedAt: 1, }; @@ -366,9 +370,9 @@ it.effect("preserves bounded request and remote selector diagnostics", () => { expect(error).toMatchObject({ operation: "click", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, clientId: "client-1", requestId: "preview-0", tabId: "tab-1", @@ -468,7 +472,7 @@ it.effect.each([ .pipe(Effect.flip); expect(error).toMatchObject({ _tag: tag, - threadId: scope.threadId, + threadId: scope.thread.threadId, }); expect(error.cause).toBe(remoteError); expect(error.message).toContain("remains on the desktop"); @@ -500,9 +504,9 @@ it.effect("distinguishes malformed remote failures", () => expect(error).toMatchObject({ operation: "status", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, clientId: "client-1", requestId: "preview-0", timeoutMs: 2_000, @@ -522,9 +526,9 @@ it.effect("rejects calls when no connected host exists", () => expect(error).toMatchObject({ operation: "status", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, }); }), ); @@ -590,8 +594,11 @@ it.effect("routes requests for background threads through an environment-level h const result = yield* broker.invoke({ scope: { ...scope, - threadId: backgroundThreadId, - providerSessionId: "provider-session-background", + thread: { + ...scope.thread, + threadId: backgroundThreadId, + providerSessionId: "provider-session-background", + }, }, operation: "status", input: {}, @@ -688,7 +695,7 @@ it.effect("pins a provider session to its initial host despite later focus chang environmentId: scope.environmentId, connectionId: "connection-stale", focused: true, - liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], + liveTabs: [{ threadId: scope.thread.threadId, tabId: PreviewTabId.make("stale-tab") }], }); expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", @@ -702,7 +709,7 @@ it.effect("pins a provider session to its initial host despite later focus chang const firstPinnedScope = { ...scope, - providerSessionId: "provider-session-first-pinned", + thread: { ...scope.thread, providerSessionId: "provider-session-first-pinned" }, }; expect( yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), @@ -720,7 +727,10 @@ it.effect("pins a provider session to its initial host despite later focus chang ).toBe("first"); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "provider-session-second-pinned" }, + scope: { + ...scope, + thread: { ...scope.thread, providerSessionId: "provider-session-second-pinned" }, + }, operation: "status", input: {}, }), @@ -756,7 +766,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l connectionId: connections.get("owner")!, focused: false, liveTabs: [ - { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, + { threadId: scope.thread.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, ], }); yield* broker.focusHost({ @@ -765,7 +775,11 @@ it.effect("prefers the live tab owner for new sessions without moving existing l connectionId: connections.get("other")!, focused: true, liveTabs: [ - { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, + { + threadId: scope.thread.threadId, + tabId: PreviewTabId.make("signed-in"), + visible: false, + }, { threadId: ThreadId.make("another-thread"), tabId: PreviewTabId.make("different-tab"), @@ -778,7 +792,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l ); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "explicit-owner" }, + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "explicit-owner" } }, tabId: PreviewTabId.make("signed-in"), operation: "snapshot", input: {}, @@ -786,7 +800,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l ).toBe("owner"); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "other-tab" }, + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "other-tab" } }, tabId: PreviewTabId.make("different-tab"), operation: "evaluate", input: {}, @@ -805,7 +819,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l ); expect( yield* broker.invoke({ - scope: { ...scope, providerSessionId: "after-tab-closed" }, + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "after-tab-closed" } }, operation: "evaluate", input: {}, }), @@ -1049,7 +1063,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" environmentId: scope.environmentId, connectionId: firstConnectionId, focused: true, - liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], + liveTabs: [{ threadId: scope.thread.threadId, tabId: firstTabId }], }); expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ host: "first", @@ -1193,9 +1207,9 @@ it.effect("fails requests assigned to the stream that is replaced", () => expect(error).toMatchObject({ operation: "status", environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, clientId: "client-1", requestId: "preview-0", timeoutMs: 15_000, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 65e3064f49a5..2e0534fec25a 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -40,7 +40,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as McpInvocationContext from "./McpInvocationContext.ts"; export interface PreviewAutomationInvokeInput { - readonly scope: McpInvocationContext.McpInvocationScope; + /** Preview tabs belong to a thread, so only thread callers reach the broker. */ + readonly scope: McpInvocationContext.McpThreadInvocationScope; readonly operation: PreviewAutomationOperation; readonly input: unknown; readonly tabId?: PreviewTabId; @@ -103,9 +104,9 @@ interface HostAssignment { interface PreviewAutomationRequestErrorContext { readonly operation: PreviewAutomationOperation; readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; - readonly threadId: McpInvocationContext.McpInvocationScope["threadId"]; + readonly threadId: McpInvocationContext.McpThreadCaller["threadId"]; readonly providerSessionId: string; - readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; + readonly providerInstanceId: McpInvocationContext.McpThreadCaller["providerInstanceId"]; readonly clientId: string; readonly connectionId: ClientConnection["connectionId"]; readonly requestId: string; @@ -160,8 +161,8 @@ const selectorDiagnosticsFromInput = ( return {}; }; -const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => - `${scope.environmentId}\u0000${scope.providerSessionId}`; +const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string => + `${scope.environmentId}\u0000${scope.thread.providerSessionId}`; const isPreviewTabId = Schema.is(PreviewTabId); @@ -498,7 +499,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => host.liveTabs.some( (tab) => - tab.threadId === input.scope.threadId && + tab.threadId === input.scope.thread.threadId && (!visibleOnly || tab.visible === true) && (input.tabId === undefined || tab.tabId === input.tabId), ); @@ -545,9 +546,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const context: PreviewAutomationRequestErrorContext = { operation: input.operation, environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, + threadId: input.scope.thread.threadId, + providerSessionId: input.scope.thread.providerSessionId, + providerInstanceId: input.scope.thread.providerInstanceId, clientId: connection.clientId, connectionId: connection.connectionId, requestId, @@ -566,9 +567,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { return yield* new PreviewAutomationNoAvailableHostError({ operation: input.operation, environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, + threadId: input.scope.thread.threadId, + providerSessionId: input.scope.thread.providerSessionId, + providerInstanceId: input.scope.thread.providerInstanceId, }); } const { connection, requestId, requestContext, requestSequence } = route; @@ -594,7 +595,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { connectionId: connection.connectionId, request: { requestId, - threadId: input.scope.threadId, + threadId: input.scope.thread.threadId, tabId: requestContext.tabId, tabIdExplicit: input.tabId !== undefined, operation: input.operation, diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts index 2f6f8a8d2134..65ec00372167 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts @@ -12,9 +12,13 @@ import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts"; const threadId = ThreadId.make("thread:metadata-caller"); const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment:metadata-test"), - threadId, - providerSessionId: "provider-session:metadata-test", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session:metadata-test", + thread: { + threadId, + providerSessionId: "provider-session:metadata-test", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(["orchestration"]), issuedAt: 1, }; @@ -74,3 +78,91 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () => expect(error.code).toBe("orchestration_error"); }), ); + +it.effect("refuses to change a thread that runs above the caller's modes", () => + Effect.gen(function* () { + const fullAccessThread = ThreadId.make("thread:metadata-full-access"); + const shells = new Map([ + [ + threadId, + { + id: threadId, + projectId: "project", + runtimeMode: "auto", + interactionMode: "default", + activeRunId: "run-live", + archivedAt: null, + providerInstanceId: "codex", + deletedAt: null, + }, + ], + [ + fullAccessThread, + { + id: fullAccessThread, + projectId: "project", + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + ], + ]); + const error = yield* Effect.gen(function* () { + const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; + return yield* service.update(scope, { + threadId: fullAccessThread, + action: "rename", + title: "Renamed from a narrower thread", + }); + }).pipe( + Effect.provide(serviceLayer((id) => Effect.succeed((shells.get(id) ?? null) as never))), + Effect.flip, + ); + + expect(error.code).toBe("runtime_mode_escalation_denied"); + }), +); + +it.effect("refuses another thread's metadata to a thread caller whose run has ended", () => + Effect.gen(function* () { + const otherThread = ThreadId.make("thread:metadata-other"); + const shells = new Map([ + [ + threadId, + { + id: threadId, + projectId: "project", + runtimeMode: "full-access", + interactionMode: "default", + activeRunId: null, + archivedAt: null, + providerInstanceId: "codex", + deletedAt: null, + }, + ], + [ + otherThread, + { + id: otherThread, + projectId: "project", + runtimeMode: "approval-required", + interactionMode: "default", + deletedAt: null, + }, + ], + ]); + const error = yield* Effect.gen(function* () { + const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; + return yield* service.update(scope, { + threadId: otherThread, + action: "rename", + title: "Renamed after the run ended", + }); + }).pipe( + Effect.provide(serviceLayer((id) => Effect.succeed((shells.get(id) ?? null) as never))), + Effect.flip, + ); + + expect(error.code).toBe("parent_not_active"); + }), +); diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index d5f029626b12..6c552511e722 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -16,6 +16,7 @@ import * as Layer from "effect/Layer"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { assertTargetWithinLimits } from "./threadAccess.ts"; export class ThreadMetadataMcpService extends Context.Service< ThreadMetadataMcpService, @@ -57,7 +58,7 @@ function commandId(input: { [ "command", "mcp", - stablePart(input.scope.providerSessionId), + stablePart(input.scope.requestNamespace), "thread-update", stablePart(input.threadId), stablePart(input.action), @@ -146,36 +147,65 @@ const make = Effect.gen(function* () { ); } - const parentShell = yield* threadManagement - .getThreadShell(scope.threadId) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to locate calling thread ${scope.threadId}: ${errorMessage(error)}`, - ), - ), + const threadId = input.threadId ?? scope.thread?.threadId; + if (threadId === undefined) { + return yield* failure( + "target_required", + "Pass threadId: this MCP client is not running inside a T3 thread.", ); - if (parentShell === null) { - return yield* failure("thread_not_found", `Calling thread ${scope.threadId} was not found.`); } - const parent = yield* threadManagement - .getThreadRecords(scope.threadId, []) + const shell = yield* threadManagement + .getThreadShell(threadId) .pipe( Effect.mapError((error) => failure( "orchestration_error", - `Unable to read calling thread ${scope.threadId}: ${errorMessage(error)}`, + `Unable to locate thread ${threadId}: ${errorMessage(error)}`, ), ), ); - const threadId = input.threadId ?? scope.threadId; - const target = - threadId === scope.threadId - ? parent - : yield* threadManagement - .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, []) - .pipe(Effect.mapError(threadLookupFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + } + // Another thread may only be changed if it runs within the caller's own modes. + if (threadId !== scope.thread?.threadId) { + const limits = + scope.thread === undefined + ? { + runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), + interactionMode: "default" as const, + } + : yield* threadManagement.getThreadShell(scope.thread.threadId).pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to locate calling thread: ${errorMessage(error)}`, + ), + ), + Effect.flatMap((caller) => + caller === null + ? Effect.fail(failure("thread_not_found", "The calling thread was not found.")) + : // Like every other cross-thread write, a thread caller needs its live run. + caller.archivedAt !== null || + caller.activeRunId === null || + caller.providerInstanceId !== scope.thread?.providerInstanceId + ? Effect.fail( + failure( + "parent_not_active", + "The calling provider no longer owns an active thread run.", + ), + ) + : Effect.succeed({ + runtimeMode: caller.runtimeMode, + interactionMode: caller.interactionMode, + }), + ), + ); + yield* assertTargetWithinLimits(limits, shell); + } + const target = yield* threadManagement + .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) + .pipe(Effect.mapError(threadLookupFailure)); const requestKey = input.clientRequestId === undefined ? yield* crypto.randomUUIDv4.pipe(Effect.orDie) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 06e997076647..6105e787d585 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -42,9 +42,13 @@ const makeScope = ( capabilities: ReadonlySet, ): McpInvocationContext.McpInvocationScope => ({ environmentId, - threadId, - providerSessionId: "provider-session-worktree-test", - providerInstanceId: ProviderInstanceId.make("claudeAgent"), + requestNamespace: "provider-session-worktree-test", + thread: { + threadId, + providerSessionId: "provider-session-worktree-test", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + }, + client: undefined, capabilities, issuedAt: 1, }); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index fb0bd85d70d3..06eb43f41e6c 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -23,7 +23,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; -import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts"; export class WorktreeMcpService extends Context.Service< WorktreeMcpService, @@ -70,6 +70,17 @@ const make = Effect.gen(function* () { // one untracked on disk. const handoffThreadsInFlight = new Set(); + // Worktree tools act on the calling thread's own checkout binding. + const requireThreadScope = (scope: McpInvocationScope) => + scope.thread === undefined + ? Effect.fail( + failure( + "thread_credential_required", + "Worktree handoff and status act as the calling T3 thread, so they need an agent running inside T3 Code.", + ), + ) + : Effect.succeed(scope as McpThreadInvocationScope); + const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("worktree") ? Effect.void @@ -77,23 +88,23 @@ const make = Effect.gen(function* () { failure("capability_denied", "This MCP credential does not grant worktree capabilities."), ); - const loadThread = (scope: McpInvocationScope) => - threadManagement.getThreadRecords(scope.threadId, []).pipe( + const loadThread = (scope: McpThreadInvocationScope) => + threadManagement.getThreadRecords(scope.thread.threadId, []).pipe( Effect.mapError((error) => error._tag === "OrchestratorProjectionError" - ? failure("thread_not_found", `Thread '${scope.threadId}' was not found.`) + ? failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`) : failure( "operation_failed", - `Unable to read thread ${scope.threadId}: ${errorMessage(error)}`, + `Unable to read thread ${scope.thread.threadId}: ${errorMessage(error)}`, ), ), Effect.filterOrFail( (projection) => projection.thread.deletedAt === null, - () => failure("thread_not_found", `Thread '${scope.threadId}' was not found.`), + () => failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`), ), ); - const loadProject = (scope: McpInvocationScope, projectId: ProjectId) => + const loadProject = (scope: McpThreadInvocationScope, projectId: ProjectId) => projects.getById(projectId).pipe( asOperationFailed(`Unable to read project ${projectId}`), Effect.flatMap( @@ -102,7 +113,7 @@ const make = Effect.gen(function* () { Effect.fail( failure( "project_not_found", - `Project '${projectId}' was not found for thread '${scope.threadId}'.`, + `Project '${projectId}' was not found for thread '${scope.thread.threadId}'.`, ), ), onSome: Effect.succeed, @@ -115,11 +126,13 @@ const make = Effect.gen(function* () { asOperationFailed("Unable to read server settings"), ); - const handoffIds = (scope: McpInvocationScope) => + const handoffIds = (scope: McpThreadInvocationScope) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => { const part = (kind: string, operation: string) => - [kind, "mcp", encodeURIComponent(scope.providerSessionId), operation, uuid].join(":"); + [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join( + ":", + ); return { commandId: CommandId.make(part("command", "worktree-handoff")), continuationCommandId: CommandId.make(part("command", "worktree-continuation")), @@ -130,13 +143,13 @@ const make = Effect.gen(function* () { ); const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* ( - scope: McpInvocationScope, + scope: McpThreadInvocationScope, input: WorktreeMcpHandoffInput, ) { const alreadyInWorktree = (worktreePath: string) => failure( "already_in_worktree", - `Thread '${scope.threadId}' is already attached to worktree '${worktreePath}'.`, + `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`, ); const projection = yield* loadThread(scope); @@ -148,7 +161,7 @@ const make = Effect.gen(function* () { if (projection.thread.archivedAt !== null) { return yield* failure( "invalid_request", - `Thread '${scope.threadId}' is archived and cannot be handed off to a worktree.`, + `Thread '${scope.thread.threadId}' is archived and cannot be handed off to a worktree.`, ); } @@ -263,7 +276,7 @@ const make = Effect.gen(function* () { Effect.catchCause((cause: Cause.Cause) => { const detail = errorMessage(Cause.squash(cause)); return Effect.logWarning(logMessage, { - threadId: scope.threadId, + threadId: scope.thread.threadId, worktreePath, detail, }).pipe(Effect.as({ status: "failed", detail } as const)); @@ -301,14 +314,14 @@ const make = Effect.gen(function* () { if (recheck.thread.archivedAt !== null) { return yield* failure( "invalid_request", - `Thread '${scope.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, + `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, ); } yield* threadManagement .dispatch({ type: "thread.metadata.update", commandId: ids.commandId, - threadId: scope.threadId, + threadId: scope.thread.threadId, branch: worktree.worktree.refName, worktreePath, expectedWorktreePath: null, @@ -357,7 +370,7 @@ const make = Effect.gen(function* () { .sendToThread({ projectId: projection.thread.projectId, commandId: ids.continuationCommandId, - threadId: scope.threadId, + threadId: scope.thread.threadId, messageId: ids.continuationMessageId, text: input.continuationPrompt, attachments: [], @@ -386,7 +399,7 @@ const make = Effect.gen(function* () { if (input.runSetupScript ?? true) { setupScript = yield* setupScriptRunner .runForThread({ - threadId: scope.threadId, + threadId: scope.thread.threadId, projectId: projection.thread.projectId, projectCwd, worktreePath, @@ -430,8 +443,9 @@ const make = Effect.gen(function* () { }); const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")( - function* (scope, input) { - yield* requireCapability(scope); + function* (callerScope, input) { + yield* requireCapability(callerScope); + const scope = yield* requireThreadScope(callerScope); // uninterruptibleMask: the guard acquisition and the registration of the // releasing finalizer happen with no interruptible gap in between. An // interrupt landing between a bare add() and the start of an ensured @@ -439,17 +453,19 @@ const make = Effect.gen(function* () { // handoff for this thread until restart. return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { - if (handoffThreadsInFlight.has(scope.threadId)) { + if (handoffThreadsInFlight.has(scope.thread.threadId)) { return Effect.fail( failure( "handoff_in_progress", - `A worktree handoff is already in progress for thread '${scope.threadId}'.`, + `A worktree handoff is already in progress for thread '${scope.thread.threadId}'.`, ), ); } - handoffThreadsInFlight.add(scope.threadId); + handoffThreadsInFlight.add(scope.thread.threadId); return restore(performHandoff(scope, input)).pipe( - Effect.ensuring(Effect.sync(() => handoffThreadsInFlight.delete(scope.threadId))), + Effect.ensuring( + Effect.sync(() => handoffThreadsInFlight.delete(scope.thread.threadId)), + ), ); }), ); @@ -457,8 +473,9 @@ const make = Effect.gen(function* () { ); const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")( - function* (scope) { - yield* requireCapability(scope); + function* (callerScope) { + yield* requireCapability(callerScope); + const scope = yield* requireThreadScope(callerScope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index bd3a4de60010..a965b0823629 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -2,6 +2,9 @@ import type { ProjectionRecordField } from "../orchestration-v2/ProjectionStore. import { CommandId, OrchestratorMcpFailure, + type ProjectId, + type ProviderInteractionMode, + type RuntimeMode, type ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; @@ -18,6 +21,24 @@ export const unavailable = () => message: "The operation could not be completed.", }); +/** + * The most a caller may hand to the threads it targets. A thread caller is + * capped by its own thread's modes; an OAuth client by the ceiling chosen + * when it was approved. + */ +export interface CallerLimits { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +export interface Caller { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly threads: ThreadManagement.ThreadManagementService["Service"]; + /** The calling thread, absent for a client signed in from outside T3. */ + readonly caller: OrchestrationV2ThreadShell | undefined; + readonly limits: CallerLimits; +} + export const readCaller = Effect.fn("mcp.readCaller")(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; if (!scope.capabilities.has("orchestration")) { @@ -27,26 +48,54 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () { }); } const threads = yield* ThreadManagement.ThreadManagementService; - const caller = yield* threads.getThreadShell(scope.threadId).pipe(Effect.mapError(unavailable)); + if (scope.thread === undefined) { + return { + scope, + threads, + caller: undefined, + limits: { + runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", + interactionMode: "default", + }, + } satisfies Caller; + } + const caller = yield* threads + .getThreadShell(scope.thread.threadId) + .pipe(Effect.mapError(unavailable)); if (caller === null || caller.deletedAt !== null) { return yield* new OrchestratorMcpFailure({ code: "thread_not_found", message: "The calling thread was not found.", }); } - return { scope, threads, caller }; + return { + scope, + threads, + caller, + limits: { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode }, + } satisfies Caller; }); -function assertLiveCaller({ - caller, - scope, -}: { - caller: OrchestrationV2ThreadShell; - scope: McpInvocationContext.McpInvocationScope; -}) { +/** + * A caller may change another thread only if that thread runs within the + * caller's own modes. Its own thread is always within them. + */ +export const assertTargetWithinLimits = ( + limits: CallerLimits, + target: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, +) => + OrchestrationMcp.resolveRuntimeMode(limits.runtimeMode, target.runtimeMode).pipe( + Effect.andThen( + OrchestrationMcp.resolveInteractionMode(limits.interactionMode, target.interactionMode), + ), + Effect.asVoid, + ); + +function assertLiveCaller({ caller, scope }: Caller) { + if (caller === undefined) return Effect.void; return caller.archivedAt !== null || caller.activeRunId === null || - caller.providerInstanceId !== scope.providerInstanceId + caller.providerInstanceId !== scope.thread?.providerInstanceId ? Effect.fail( new OrchestratorMcpFailure({ code: "parent_not_active", @@ -55,34 +104,91 @@ function assertLiveCaller({ ) : Effect.void; } + +/** + * Mutations from a thread need that thread's live run, so an agent whose turn + * ended cannot keep acting. An OAuth client has no run; its session and + * ceiling are its authority. + */ export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () { const context = yield* readCaller(); yield* assertLiveCaller(context); return context; }); -/** Resolve the credential's project before looking up a caller-supplied thread. */ +/** + * Actions that change the environment itself (projects, preferences, launching + * outside a project) need full access: a thread caller in full-access/default + * mode, or a client approved with a full-access ceiling. + */ +export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* ( + message: string, +) { + const context = yield* readMutationCaller(); + if ( + (context.caller !== undefined && context.caller.archivedAt !== null) || + context.limits.runtimeMode !== "full-access" || + context.limits.interactionMode !== "default" + ) { + return yield* new OrchestratorMcpFailure({ code: "capability_denied", message }); + } + return context; +}); + +/** A target project: the one passed, else the calling thread's. */ +export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) => + projectId !== undefined + ? Effect.succeed(projectId) + : context.caller !== undefined + ? Effect.succeed(context.caller.projectId) + : Effect.fail( + new OrchestratorMcpFailure({ + code: "target_required", + message: "Pass projectId: this MCP client is not running inside a T3 thread.", + }), + ); + +/** A target thread: the one passed, else the calling thread. */ +const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) => + threadId !== undefined + ? Effect.succeed(threadId) + : context.caller !== undefined + ? Effect.succeed(context.caller.id) + : Effect.fail( + new OrchestratorMcpFailure({ + code: "target_required", + message: "Pass threadId: this MCP client is not running inside a T3 thread.", + }), + ); + +/** Load a target thread anywhere in the environment; an omitted id means the calling thread. */ export const readThread = Effect.fn("mcp.readThread")(function* < K extends ProjectionRecordField = never, >(threadId?: ThreadId, fields: ReadonlyArray = []) { - const { scope, threads, caller } = yield* readCaller(); - const projection = yield* threads - .getProjectThreadRecords( - { projectId: caller.projectId, threadId: threadId ?? caller.id }, - fields, - { turnItemTypes: ["user_input_request"] }, - ) + const context = yield* readCaller(); + const targetId = yield* resolveThreadId(context, threadId); + const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); + if (shell === null || shell.deletedAt !== null) { + return yield* new OrchestratorMcpFailure({ + code: "thread_not_found", + message: "The thread was not found.", + }); + } + const projection = yield* context.threads + .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { + turnItemTypes: ["user_input_request"], + }) .pipe( Effect.mapError((error) => error._tag === "ThreadManagementThreadNotFoundError" ? new OrchestratorMcpFailure({ code: "thread_not_found", - message: "The thread was not found in the calling project.", + message: "The thread was not found.", }) : unavailable(), ), ); - return { scope, threads, caller, projection }; + return { ...context, projection }; }); export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* < @@ -90,14 +196,7 @@ export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* >(threadId?: ThreadId, fields: ReadonlyArray = []) { const context = yield* readThread(threadId, fields); yield* assertLiveCaller(context); - yield* OrchestrationMcp.resolveRuntimeMode( - context.caller.runtimeMode, - context.projection.thread.runtimeMode, - ); - yield* OrchestrationMcp.resolveInteractionMode( - context.caller.interactionMode, - context.projection.thread.interactionMode, - ); + yield* assertTargetWithinLimits(context.limits, context.projection.thread); return context; }); diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 42866c8db872..e02110b63dea 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -47,7 +47,7 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ }), t3_thread_send_attachments: (input) => Effect.gen(function* () { - const { caller, projection, scope } = yield* readWritableThread(input.threadId, ["messages"]); + const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]); if (projection.thread.archivedAt !== null) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -60,11 +60,11 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ const commandId = yield* newCommandId(); const messageId = MessageId.make(commandId); const result = yield* ThreadMessageIntake.sendToThread({ - projectId: caller.projectId, + projectId: projection.thread.projectId, threadId: projection.thread.id, commandId, messageId, - senderThreadId: scope.threadId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), text: input.message ?? "", attachments, mode: "auto", diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 55820f035c17..f93bb06729e8 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -47,7 +47,7 @@ const AttachmentDiscardTool = Tool.make("t3_attachment_discard", { const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, description: - "Send uploaded attachments to this thread or another thread in the calling project. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", + "Send uploaded attachments to this thread or any other thread in this environment. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 339e5eb9b2a8..99834a5ce5b4 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -14,7 +14,11 @@ import { McpAttachmentInput } from "./attachment/input.ts"; import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts"; +import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import { OrchestratorToolkit } from "./orchestrator/tools.ts"; @@ -78,9 +82,13 @@ it("publishes unique tool names with reference-free object-root inputs", () => { const threadId = ThreadId.make("mcp-core-thread"); const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("mcp-core-environment"), - threadId, - providerSessionId: "mcp-core-session", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "mcp-core-session", + thread: { + threadId, + providerSessionId: "mcp-core-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration"]), }; @@ -207,3 +215,236 @@ it.effect("resolves reused attachment references from stored metadata", () => expect(failure.code).toBe("invalid_request"); }), ); + +const clientScope = ( + runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access", +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("mcp-core-environment"), + requestNamespace: "client:session-1", + thread: undefined, + client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling }, + issuedAt: 0, + capabilities: new Set(["orchestration", "worktree", "pull-requests"]), +}); + +it.effect("a client caller targets any thread within its ceiling and cannot act as a thread", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = ( + name: string, + args: Record, + invocation: McpInvocationContext.McpInvocationScope, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const untargeted = yield* call("t3_thread_organize", { action: "pin" }, clientScope("auto")); + expect(untargeted.structuredContent).toMatchObject({ code: "target_required" }); + + const pinned = yield* call( + "t3_thread_organize", + { action: "pin", threadId: "other-project-thread" }, + clientScope("auto"), + ); + expect(pinned.isError).toBe(false); + expect(pinned.structuredContent).toMatchObject({ sequence: 7 }); + + const aboveCeiling = yield* call( + "t3_thread_organize", + { action: "pin", threadId: "other-project-thread" }, + clientScope("approval-required"), + ); + expect(aboveCeiling.structuredContent).toMatchObject({ + code: "runtime_mode_escalation_denied", + }); + + const forked = yield* call( + "t3_thread_fork", + { sourcePoint: { type: "latest_stable" } }, + clientScope("auto"), + ); + expect(forked.structuredContent).toMatchObject({ code: "target_required" }); + }).pipe( + Effect.provide( + McpHttpServer.ThreadToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: ThreadId.make("other-project-thread"), + projectId: "other-project", + deletedAt: null, + } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("other-project-thread"), + projectId: "other-project", + runtimeMode: "auto", + interactionMode: "default", + deletedAt: null, + }, + } as never), + dispatch: () => Effect.succeed({ sequence: 7 } as never), + }), + ), + ), + ), + ), +); + +it.effect("refuses act-as-caller tools to a client caller", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "delegate_task", + arguments: { task: "Review", mode: "async" }, + }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + clientScope("full-access"), + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.structuredContent).toMatchObject({ code: "thread_credential_required" }); + }).pipe( + Effect.provide( + McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + ), + ), + ), +); + +it.effect("a caller cannot rewrite a scheduled task that runs above its own modes", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const update = yield* call("update_scheduled_task", { + scheduledTaskId: "task-full-access", + prompt: "Run something else", + }); + expect(update.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" }); + expect(remove.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const allowed = yield* call("update_scheduled_task", { + scheduledTaskId: "task-auto", + enabled: false, + }); + expect(allowed.isError).toBe(false); + }).pipe( + Effect.provide( + McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide( + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => + Effect.succeed({ + tasks: [ + scheduledTask("task-full-access", "full-access"), + scheduledTask("task-auto", "auto"), + ], + }), + upsert: (input) => + Effect.succeed({ + task: { ...(scheduledTask(input.id ?? "task-auto", "auto") as object), ...input }, + } as never), + }), + ), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + ), + ), + ), +); + +function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never { + return { + id, + title: id, + prompt: "Check the build", + enabled: true, + projectId: "project-a", + threadId: null, + schedule: { type: "interval", everyMs: 3_600_000 }, + workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: true }, + modelSelection: { instanceId: "codex", model: "gpt-5" }, + runtimeMode, + interactionMode: "default", + createdBy: "user", + creationSource: "web", + nextRunAt: null, + lastRunStatus: "never", + lastRunAt: null, + lastRunThreadId: null, + lastRunError: null, + runCount: 0, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + } as never; +} + +it.effect("a caller cannot interrupt a thread that runs above its own modes", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" }); + }).pipe( + Effect.provide( + McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ projectId: "project-a", deletedAt: null } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("full-access-thread"), + projectId: "project-a", + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + runs: [], + } as never), + interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), + }), + ), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + ), + ), + ), +); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 2a8172a63411..6c6850673a39 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -66,7 +66,7 @@ export function agentDeviceQuickStart( ].join("\n"); } -const requireDeviceAccess = McpInvocationContext.requireMcpCapability("device").pipe( +const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("device").pipe( Effect.mapError( () => new DeviceToolUnavailableError({ @@ -132,7 +132,7 @@ const handlers = { } const hostId = input?.hostId; const open = state.sessions - .filter((session) => session.threadId === scope.threadId) + .filter((session) => session.threadId === scope.thread.threadId) .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId })); return { hostStatuses: Object.fromEntries( @@ -159,12 +159,12 @@ const handlers = { const target = yield* pickDevice(state.devices, input); // Resolve consent and agent connectivity before booting or registering a session. const agentArgs = yield* devices.agentTarget({ - threadId: scope.threadId, + threadId: scope.thread.threadId, hostId: target.hostId, deviceId: target.id, }); const session = yield* devices.open({ - threadId: scope.threadId, + threadId: scope.thread.threadId, hostId: target.hostId, deviceId: target.id, platform: target.platform, @@ -207,7 +207,7 @@ const handlers = { Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; - const sessions = yield* devices.sessionsForThread(scope.threadId); + const sessions = yield* devices.sessionsForThread(scope.thread.threadId); const target = input.deviceId !== undefined ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId } @@ -234,7 +234,7 @@ const handlers = { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; yield* devices.close({ - threadId: scope.threadId, + threadId: scope.thread.threadId, ...(input.hostId === undefined ? {} : { hostId: input.hostId }), ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }), ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }), diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index 2c01841611dc..f95ac72bbdb9 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -4,7 +4,7 @@ import * as Environment from "../../../environment/ServerEnvironment.ts"; import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; import * as Settings from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; +import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts"; import { EnvironmentToolkit } from "./tools.ts"; export function preferences(settings: ServerSettings) { @@ -30,7 +30,11 @@ export function preferences(settings: ServerSettings) { } const access = (writable = false) => Effect.gen(function* () { - const context = yield* writable ? readMutationCaller() : readCaller(); + const context = yield* writable + ? readFullAccessCaller( + "Preference updates require a live full-access/default thread or a full-access client.", + ) + : readCaller(); const environment = yield* Environment.ServerEnvironment; const descriptor = yield* environment.getDescriptor; if (descriptor.environmentId !== context.scope.environmentId) @@ -57,23 +61,15 @@ export const EnvironmentHandlersLive = EnvironmentToolkit.toLayer({ Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; - return yield* executor.withLock( - scope.threadId, - Effect.gen(function* () { - const { caller, settings } = yield* access(true); - if ( - caller.archivedAt !== null || - caller.runtimeMode !== "full-access" || - caller.interactionMode !== "default" - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Preference updates require a live full-access/default thread.", - }); - return preferences( - yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), - ); - }), - ); + const update = Effect.gen(function* () { + const { settings } = yield* access(true); + return preferences( + yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), + ); + }); + // A thread caller serializes with its own turn; a client has no thread to lock. + return yield* scope.thread === undefined + ? update + : executor.withLock(scope.thread.threadId, update); }), }); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 7dbbd21cb624..d45197b22c9c 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -36,11 +36,11 @@ const handlers = { const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.scheduleTask(scope, input); }), - list_scheduled_tasks: () => + list_scheduled_tasks: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.listScheduledTasks(scope); + return yield* service.listScheduledTasks(scope, input); }), update_scheduled_task: (input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index a86f1ce214c5..540de4e1ca06 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -7,6 +7,7 @@ import { OrchestratorMcpDeleteScheduledTaskInput, OrchestratorMcpDeleteScheduledTaskResult, OrchestratorMcpFailure, + OrchestratorMcpListScheduledTasksInput, OrchestratorMcpListScheduledTasksResult, OrchestratorMcpScheduleTaskInput, OrchestratorMcpScheduleTaskResult, @@ -44,7 +45,7 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this T3 thread. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -57,7 +58,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -70,7 +71,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -84,7 +85,7 @@ const TaskStatusTool = Tool.make("task_status", { const TaskCancelTool = Tool.make("task_cancel", { description: - "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.", + "Needs an agent running inside a T3 thread. Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.", parameters: OrchestratorMcpTaskCancelInput, success: OrchestratorMcpTaskCancelResult, failure: OrchestratorMcpFailure, @@ -96,7 +97,7 @@ const TaskCancelTool = Tool.make("task_cancel", { export const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. By default (bindToCurrentThread=true) each run posts into THIS thread; use false only when the user wants a fresh top-level thread per run. Provider, model, and runtime settings inherit from this thread. Report the returned schedule and nextRunAt after success.", + "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true); use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread. Report the returned schedule and nextRunAt after success.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -108,8 +109,9 @@ export const ScheduleTaskTool = Tool.make("schedule_task", { .annotate(Tool.OpenWorld, true); const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { + parameters: OrchestratorMcpListScheduledTasksInput, description: - "List the recurring scheduled tasks in the calling thread's project, including their id, schedule, prompt, enabled state, bound thread, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.", + "List recurring scheduled tasks in a project (omit projectId for the calling thread's project, or every project when there is no calling thread), including their id, schedule, prompt, enabled state, bound thread, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.", success: OrchestratorMcpListScheduledTasksResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -146,7 +148,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { export const CreateThreadsTool = Tool.make("create_threads", { description: - "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", + "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", parameters: OrchestratorMcpCreateThreadsInput, success: OrchestratorMcpCreateThreadsResult, failure: OrchestratorMcpFailure, @@ -159,7 +161,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in the calling thread's project, newest first. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor. Threads from other projects are never exposed.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -173,7 +175,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from a T3 thread in the calling project, or from a thread the user attached to this conversation as context. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, @@ -187,7 +189,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", { export const ThreadUpdateTool = Tool.make("t3_thread_update", { description: - "Update metadata for a thread in the calling project. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", + "Update metadata for a thread. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", parameters: ThreadMetadataMcpUpdateInput, success: ThreadMetadataMcpUpdateResult, failure: OrchestratorMcpFailure, @@ -200,7 +202,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to a T3 thread in the calling project. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, @@ -227,7 +229,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", { const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { description: - "Request interruption of a running turn in a T3 thread in the calling project. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", + "Request interruption of a running turn in any T3 thread in this environment. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadInterruptInput, success: OrchestratorMcpThreadInterruptResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index b70e68862683..9bbbc13a7109 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -60,7 +60,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( import("@t3tools/contracts").PreviewAutomationError, McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker > { - const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview"); const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; let targetTabId = tabId; const result = yield* broker.invoke({ @@ -218,7 +218,7 @@ const handlers = { invokeTargeted("recordingStart", input ?? {}), preview_recording_stop: (input) => Effect.gen(function* () { - const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview"); const { tabId, ...operationInput } = input; const response = yield* invoke( "recordingStop", @@ -226,7 +226,7 @@ const handlers = { PREVIEW_RECORDING_STOP_TIMEOUT_MS, tabId, ); - const artifact = yield* claimPreviewRecording(scope.threadId, response.result); + const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result); return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; }), } satisfies Parameters[0]; diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts index 8764dc55fd21..700eeb808154 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -35,9 +35,13 @@ it.effect.each([ const effective = resolveProjectSettings(settings, projectId).settings; const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("preview-controls-environment"), - threadId, - providerSessionId: "preview-controls-provider-session", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "preview-controls-provider-session", + thread: { + threadId, + providerSessionId: "preview-controls-provider-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), issuedAt: 0, }; diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts index 213a7437353a..16f2449aad35 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -1,19 +1,19 @@ import * as Effect from "effect/Effect"; import * as Preview from "../../../preview/Manager.ts"; -import { requireMcpCapability } from "../../McpInvocationContext.ts"; +import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; import { unavailable } from "../../threadAccess.ts"; import { PreviewControlsToolkit } from "./tools.ts"; const access = Effect.gen(function* () { // The preview capability already reflects the calling project's access setting. - const scope = yield* requireMcpCapability("preview"); + const scope = yield* requireThreadMcpCapability("preview"); return { scope, manager: yield* Preview.PreviewManager }; }); export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({ t3_preview_list: (input) => Effect.gen(function* () { const { scope, manager } = yield* access; - const result = yield* manager.list({ threadId: scope.threadId }); + const result = yield* manager.list({ threadId: scope.thread.threadId }); const start = input.cursor ?? 0; const end = start + (input.limit ?? 20); return { @@ -26,7 +26,7 @@ export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({ Effect.gen(function* () { const { scope, manager } = yield* access; yield* manager - .close({ threadId: scope.threadId, tabId: input.tabId }) + .close({ threadId: scope.thread.threadId, tabId: input.tabId }) .pipe(Effect.mapError(unavailable)); return {}; }), diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 68dc746d2499..fa3569906739 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, ThreadId, type OrchestrationV2ThreadShell, + type Project as ProjectRecord, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -44,9 +45,13 @@ it.effect("attributes a launched thread's first message to the calling thread", NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, + requestNamespace: "session", + thread: { + threadId: sourceThreadId, + providerSessionId: "session", + providerInstanceId, + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), @@ -106,9 +111,13 @@ it.effect("launches a scratch thread into the Scratch project", () => NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, + requestNamespace: "session", + thread: { + threadId: sourceThreadId, + providerSessionId: "session", + providerInstanceId, + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), @@ -194,9 +203,13 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, + requestNamespace: "session", + thread: { + threadId: sourceThreadId, + providerSessionId: "session", + providerInstanceId, + }, + client: undefined, issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), @@ -263,3 +276,87 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () expect(named).toEqual(["Pinball Stats"]); }), ); + +const clientLaunchHarness = (input: { + readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; + readonly launched: Array; +}) => { + const projectId = ProjectId.make("project:client-target"); + const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; + const dependencies = Layer.mergeAll( + NodeCrypto.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:session-1", + thread: undefined, + client: { + sessionId: "session-1", + label: "Claude Code", + runtimeModeCeiling: input.runtimeModeCeiling, + }, + issuedAt: 0, + capabilities: new Set(["orchestration" as const]), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: (launch) => { + input.launched.push(launch); + return Effect.succeed({ + threadId: launch.threadId, + projection: { + thread: { + id: launch.threadId, + projectId: launch.projectId, + modelSelection: launch.modelSelection, + }, + runs: [], + }, + resumed: false, + } as unknown as ThreadLaunch.ThreadLaunchResult); + }, + }), + Layer.mock(Project.ProjectService)({ + getById: (id) => + Effect.succeed( + id === projectId + ? Option.some({ id, defaultModelSelection: modelSelection } as unknown as ProjectRecord) + : Option.none(), + ), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ); + return { projectId, modelSelection, dependencies }; +}; + +it.effect("a client launches at its ceiling with the project's default model", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, modelSelection, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto-accept-edits", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide(ProjectHandlersLive.pipe(Layer.provide(dependencies))), + ); + const handle = (params: Parameters>[1]) => + toolkit + .handle("t3_thread_launch", params) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); + expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); + expect(launched[0]?.runtimeMode).toBe("auto-accept-edits"); + expect(launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); + + const escalated = yield* handle({ title: "Fix", projectId, runtimeMode: "full-access" }); + expect(escalated.at(-1)?.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + + const untargeted = yield* handle({ title: "Fix" }); + expect(untargeted.at(-1)?.result).toMatchObject({ code: "target_required" }); + expect(launched).toHaveLength(1); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 346e884f3032..e97119f3132d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -6,7 +6,15 @@ import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; -import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; +import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; +import { + newCommandId, + readCaller, + readFullAccessCaller, + readMutationCaller, + resolveProjectId, + unavailable, +} from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; function projectFailure(error: Project.ProjectServiceError) { @@ -25,27 +33,30 @@ const access = Effect.gen(function* () { return yield* Project.ProjectService; }); const mutation = Effect.gen(function* () { - const { caller } = yield* readMutationCaller(); - if ( - caller.archivedAt !== null || - caller.runtimeMode !== "full-access" || - caller.interactionMode !== "default" - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Project changes require a live full-access/default calling thread.", - }); + yield* readFullAccessCaller( + "Project changes require a live full-access/default calling thread or a full-access client.", + ); return yield* Project.ProjectService; }); export const ProjectHandlersLive = ProjectToolkit.toLayer({ t3_thread_launch: (input) => Effect.gen(function* () { - const { caller, scope } = yield* readMutationCaller(); - if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default") + const context = yield* readMutationCaller(); + const { caller, limits } = context; + // A thread caller launches only as itself (full-access/default), as before. A client + // launches anything up to its ceiling. + if ( + caller !== undefined && + (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default") + ) return yield* new OrchestratorMcpFailure({ code: "capability_denied", message: "Project launches require a full-access/default calling thread.", }); + const runtimeMode = yield* resolveRuntimeMode( + limits.runtimeMode, + input.runtimeMode ?? caller?.runtimeMode, + ); const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -76,22 +87,38 @@ export const ProjectHandlersLive = ProjectToolkit.toLayer({ }), ), )).projectId - : (input.projectId ?? caller.projectId); + : yield* resolveProjectId(context, input.projectId); + const modelSelection = + input.modelSelection ?? + caller?.modelSelection ?? + (yield* Project.ProjectService.pipe( + Effect.flatMap((projects) => projects.getById(projectId)), + Effect.mapError(unavailable), + Effect.map((project) => + Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)), + ), + )); + if (modelSelection === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", + }); const result = yield* ThreadMessageIntake.launchThread({ commandId, threadId, projectId, title: input.title, - modelSelection: input.modelSelection ?? caller.modelSelection, - runtimeMode: input.runtimeMode ?? caller.runtimeMode, - interactionMode: input.interactionMode ?? caller.interactionMode, + modelSelection, + runtimeMode, + interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default", workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, ...(input.message === undefined && attachments.length === 0 ? {} : { initialMessage: { messageId, - senderThreadId: scope.threadId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), text: input.message ?? "", attachments, }, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 693a76854bb6..e42b0012dd69 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -102,7 +102,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default caller.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default calling thread; a caller outside a T3 thread launches up to its approved permission mode.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index e329ade90d9e..93485a41175c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -38,9 +38,13 @@ const invocation = ( capabilities: ReadonlyArray, ): McpInvocationContext.McpInvocationScope => ({ environmentId: EnvironmentId.make("environment-1"), - threadId: THREAD_ID, - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), + requestNamespace: "provider-session-1", + thread: { + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, capabilities: new Set(capabilities), issuedAt: 1, }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 97af65e1a7bc..e43208256ea9 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -26,6 +26,7 @@ import * as Option from "effect/Option"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { assertTargetWithinLimits } from "../../threadAccess.ts"; import { type ListThreadPullRequestsResult, PullRequestLinkFailedError, @@ -38,6 +39,8 @@ import { type PullRequestTargetInput, PullRequestWatchFailedError, PullRequestThreadNotFoundError, + PullRequestThreadAboveLimitsError, + PullRequestThreadRequiredError, PullRequestsToolkit, type ThreadPullRequestEntry, } from "./tools.ts"; @@ -168,18 +171,64 @@ const make = Effect.gen(function* () { | typeof PullRequestUnlinkFailedError | typeof PullRequestListFailedError | typeof PullRequestWatchFailedError, + requested: ThreadId | undefined, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); + const threadId = requested ?? scope.thread?.threadId; + if (threadId === undefined) { + return yield* new PullRequestThreadRequiredError(); + } const thread = yield* engine - .getThreadShell(scope.threadId) + .getThreadShell(threadId) .pipe(Effect.map(Option.fromNullishOr)) .pipe(Effect.mapError((cause) => new Failure({ cause }))); - if (Option.isNone(thread)) { - return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); + if (Option.isNone(thread) || thread.value.deletedAt !== null) { + return yield* new PullRequestThreadNotFoundError({ threadId }); } return thread.value; }); + /** + * A thread whose pull requests the caller may change: its own, or one that + * runs within the caller's modes. + */ + const requireWritableThread = Effect.fn("PullRequestsToolkit.requireWritableThread")(function* ( + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestWatchFailedError, + requested: ThreadId | undefined, + ) { + const thread = yield* requireThread(Failure, requested); + const scope = yield* McpInvocationContext.McpInvocationContext; + if (thread.id === scope.thread?.threadId) return thread; + const limits = + scope.thread === undefined + ? { + runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), + interactionMode: "default" as const, + } + : yield* engine.getThreadShell(scope.thread.threadId).pipe( + Effect.mapError((cause) => new Failure({ cause })), + Effect.map((caller) => + // A thread caller changes other threads only while its own run is live. + caller === null || + caller.archivedAt !== null || + caller.activeRunId === null || + caller.providerInstanceId !== scope.thread?.providerInstanceId + ? undefined + : { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode }, + ), + ); + if (limits === undefined) { + return yield* new PullRequestThreadAboveLimitsError({ threadId: thread.id }); + } + yield* assertTargetWithinLimits(limits, thread).pipe( + Effect.mapError(() => new PullRequestThreadAboveLimitsError({ threadId: thread.id })), + ); + return thread; + }); + const projectOf = ( thread: OrchestrationV2ThreadShell, Failure: @@ -217,7 +266,7 @@ const make = Effect.gen(function* () { input: PullRequestTargetInput, watching: boolean, ) { - const thread = yield* requireThread(PullRequestWatchFailedError); + const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestWatchFailedError); const target = yield* resolveTarget(input, project); const watchedLink = (shell: OrchestrationV2ThreadShell) => @@ -241,7 +290,7 @@ const make = Effect.gen(function* () { ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), }) .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); - const after = yield* requireThread(PullRequestWatchFailedError); + const after = yield* requireThread(PullRequestWatchFailedError, thread.id); return { host: target.host, repository: target.repository, @@ -255,7 +304,7 @@ const make = Effect.gen(function* () { return PullRequestsToolkit.of({ link_pull_request: (input) => Effect.gen(function* () { - const thread = yield* requireThread(PullRequestLinkFailedError); + const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestLinkFailedError); const target = yield* resolveTarget(input, project); const existing = threadPullRequestsOf(thread).find((link) => @@ -285,7 +334,7 @@ const make = Effect.gen(function* () { }), unlink_pull_request: (input) => Effect.gen(function* () { - const thread = yield* requireThread(PullRequestUnlinkFailedError); + const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestUnlinkFailedError); const target = yield* resolveTarget(input, project); if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target))) @@ -316,8 +365,10 @@ const make = Effect.gen(function* () { wasLinked, }; }), - list_thread_pull_requests: () => - requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), + list_thread_pull_requests: (input) => + requireThread(PullRequestListFailedError, input.threadId).pipe( + Effect.map(listThreadPullRequests), + ), watch_pull_request: (input) => setWatching(input, true), unwatch_pull_request: (input) => setWatching(input, false), }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 52ac473a3d50..f17b22204f45 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -4,6 +4,7 @@ import { PullRequestState, ThreadPullRequestLinkSource, TrimmedNonEmptyString, + ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Tool from "effect/unstable/ai/Tool"; @@ -28,6 +29,11 @@ const REGISTER_EVERY_PR = * the host CLI handed back. */ export const PullRequestTargetInput = Schema.Struct({ + threadId: Schema.optional( + ThreadId.annotate({ + description: "Thread to act on. Omit for this thread.", + }), + ), url: Schema.optional( TrimmedNonEmptyString.annotate({ description: @@ -81,6 +87,24 @@ export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestThreadRequiredError", + {}, +) { + override get message(): string { + return "Pass threadId: this MCP client is not running inside a T3 thread."; + } +} + +export class PullRequestThreadAboveLimitsError extends Schema.TaggedError()( + "PullRequestThreadAboveLimitsError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} cannot be changed from here: it runs with broader permissions than this caller, or the calling thread has no active run.`; + } +} + export class PullRequestThreadNotFoundError extends Schema.TaggedError()( "PullRequestThreadNotFoundError", { threadId: Schema.String }, @@ -140,6 +164,8 @@ export const PullRequestToolError = Schema.Union([ PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestThreadRequiredError, + PullRequestThreadAboveLimitsError, PullRequestThreadNotFoundError, PullRequestLinkFailedError, PullRequestUnlinkFailedError, @@ -245,7 +271,12 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { .annotate(Tool.OpenWorld, false); const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { - description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + description: `List the pull requests linked to a thread (omit threadId for this thread) with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + parameters: Schema.Struct({ + threadId: Schema.optional( + ThreadId.annotate({ description: "Thread to list. Omit for this thread." }), + ), + }), success: ListThreadPullRequestsResult, failure: PullRequestToolError, dependencies, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index c0f5d135b6cc..b3c3b75eb32e 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -13,7 +13,7 @@ import { modelSelectionCommandType } from "@t3tools/shared/model"; import { newCommandId, readCaller, - readMutationCaller, + readFullAccessCaller, readThread, readWritableThread, unavailable, @@ -78,22 +78,15 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ run_scheduled_task_now: (input) => Effect.gen(function* () { - const { caller } = yield* readMutationCaller(); - if ( - caller.archivedAt !== null || - caller.runtimeMode !== "full-access" || - caller.interactionMode !== "default" - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Running a scheduled task requires a live full-access/default thread.", - }); + yield* readFullAccessCaller( + "Running a scheduled task requires a live full-access/default thread or a full-access client.", + ); const scheduler = yield* ScheduledTasks.ScheduledTaskService; const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable)); - if (!tasks.some((task) => task.id === input.taskId && task.projectId === caller.projectId)) + if (!tasks.some((task) => task.id === input.taskId)) return yield* new OrchestratorMcpFailure({ code: "invalid_request", - message: "The task was not found in the calling project.", + message: "The scheduled task was not found.", }); const { task } = yield* scheduler .runNow({ id: input.taskId }) @@ -109,13 +102,22 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ t3_thread_search: (input) => Effect.gen(function* () { const { caller } = yield* readCaller(); + const { projectId: requested, ...query } = input; + // Like the other project tools, an omitted project means the caller's own; a client + // outside a thread searches every project. + const projectId = requested ?? caller?.projectId; const threadSearch = yield* ThreadSearch.ThreadSearch; - const result = yield* threadSearch.search(input).pipe(Effect.mapError(unavailable)); - return { matches: result.matches.filter((match) => match.projectId === caller.projectId) }; + const result = yield* threadSearch.search(query).pipe(Effect.mapError(unavailable)); + return { + matches: + projectId === undefined + ? result.matches + : result.matches.filter((match) => match.projectId === projectId), + }; }), t3_thread_fork: (input) => Effect.gen(function* () { - const { threads, projection } = yield* readWritableThread(); + const { threads, projection } = yield* readWritableThread(input.threadId); const commandId = yield* newCommandId(); const targetThreadId = ThreadId.make(`${commandId}:fork`); const result = yield* threads @@ -134,12 +136,13 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ }), t3_thread_merge_back: (input) => Effect.gen(function* () { - const { threads, caller } = yield* readWritableThread(input.targetThreadId); - const result = yield* threads + const context = yield* readWritableThread(input.targetThreadId); + const source = yield* readWritableThread(input.sourceThreadId); + const result = yield* context.threads .dispatch({ type: "thread.merge_back", commandId: yield* newCommandId(), - sourceThreadId: caller.id, + sourceThreadId: source.projection.thread.id, targetThreadId: input.targetThreadId, sourcePoint: input.sourcePoint, createdBy: "agent", @@ -179,7 +182,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ const { threads, projection: { thread }, - } = yield* readWritableThread(); + } = yield* readWritableThread(input.threadId); const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection); const result = yield* threads .dispatch({ diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 1517763b936e..bc2188037246 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -17,6 +17,7 @@ import { ThreadId, RunId, NonNegativeInt, + ProjectId, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; @@ -29,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread in the calling project. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.", + "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), action: Schema.Literals([ @@ -91,7 +92,7 @@ const QueueListTool = Tool.make("t3_queue_list", { .annotate(Tool.Destructive, false); const QueueReadTool = Tool.make("t3_queue_read", { ...commandTool, - description: "Read up to 16,000 characters of a queued message in the calling project.", + description: "Read up to 16,000 characters of a queued message. Omit threadId for this thread.", parameters: Schema.Struct(queueTarget), success: queueEntry, }) @@ -146,7 +147,7 @@ const pendingRequest = Schema.Struct({ const PendingRequestListTool = Tool.make("t3_pending_request_list", { ...commandTool, description: - "List pending user questions in a thread in the calling project. Approval requests are not included.", + "List pending user questions in a thread. Omit threadId for this thread. Approval requests are not included.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }), }) @@ -173,7 +174,7 @@ const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", { const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { ...commandTool, description: - "Read a thread's provider/model selection and modes in the calling project. orchestrator_capabilities lists available providers and models.", + "Read a thread's provider/model selection and modes. Omit threadId for this thread. orchestrator_capabilities lists available providers and models.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), success: Schema.Struct({ threadId: ThreadId, @@ -187,16 +188,20 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { const ThreadConfigureTool = Tool.make("t3_thread_configure", { ...commandTool, description: - "Set this calling thread's provider, model and options with the existing selection command. This does not change permission modes or other threads. Use orchestrator_capabilities to choose a selection.", - parameters: Schema.Struct({ modelSelection: ModelSelection }), + "Set a thread's provider, model and options with the existing selection command. Omit threadId for this thread. This does not change permission modes. Use orchestrator_capabilities to choose a selection.", + parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), + modelSelection: ModelSelection, + }), }).annotate(Tool.Destructive, true); const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }); const ThreadForkTool = Tool.make("t3_thread_fork", { ...commandTool, description: - "Fork this thread from a stable run or checkpoint using the existing fork command. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.", + "Fork a thread from a stable run or checkpoint using the existing fork command. Omit threadId to fork this thread. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.", parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), sourcePoint: OrchestrationV2ThreadForkSourcePoint, title: Schema.optional(TrimmedNonEmptyString), }), @@ -205,8 +210,9 @@ const ThreadForkTool = Tool.make("t3_thread_fork", { const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { ...commandTool, description: - "Merge context from this thread back to a related thread in the same project. Existing lineage and transfer rules apply.", + "Merge context from a thread back to a related thread in the same project. Omit sourceThreadId to merge from this thread. Existing lineage and transfer rules apply.", parameters: Schema.Struct({ + sourceThreadId: Schema.optional(ThreadId), targetThreadId: ThreadId, sourcePoint: OrchestrationV2ThreadForkSourcePoint, }), @@ -214,7 +220,7 @@ const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { }).annotate(Tool.Destructive, true); const ThreadTransfersTool = Tool.make("t3_thread_transfers", { ...commandTool, - description: "Read context transfer status for a thread in the calling project.", + description: "Read context transfer status for a thread. Omit threadId for this thread.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), success: Schema.Struct({ transfers: Schema.Array( @@ -233,8 +239,11 @@ const ThreadTransfersTool = Tool.make("t3_thread_transfers", { const ThreadSearchTool = Tool.make("t3_thread_search", { ...commandTool, description: - "Search active thread titles and content with the app's existing bounded search. Returns matches in the calling project from the global top matches; other-project matches are omitted, so this may return fewer than limit. No pagination or exhaustive-result guarantee.", - parameters: OrchestrationSearchThreadsInput, + "Search active thread titles and content with the app's existing bounded search. Matches are limited to one project (projectId, else the calling thread's project) out of the global top matches, so this may return fewer than limit. A caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.", + parameters: Schema.Struct({ + ...OrchestrationSearchThreadsInput.fields, + projectId: Schema.optional(ProjectId), + }), success: OrchestrationSearchThreadsResult, dependencies: [...commandTool.dependencies, ThreadSearch.ThreadSearch], }) @@ -244,7 +253,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { ...commandTool, description: - "Run a scheduled task in the calling project now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.", + "Run a scheduled task now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.", parameters: Schema.Struct({ taskId: ScheduledTaskId }), success: Schema.Struct({ taskId: ScheduledTaskId, diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 0ecabdaecb97..dbb7c59f4fb7 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -2,7 +2,7 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as GitWorkflow from "../../../git/GitWorkflowService.ts"; import * as Project from "../../../project/ProjectService.ts"; -import { readCaller, unavailable } from "../../threadAccess.ts"; +import { readThread, unavailable } from "../../threadAccess.ts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -18,9 +18,12 @@ const handlers = { code: "capability_denied", message: "This credential cannot inspect worktrees.", }); - const { caller } = yield* readCaller(); + const { threadId, ...refs } = input; + const { + projection: { thread }, + } = yield* readThread(threadId); const projects = yield* Project.ProjectService; - const project = yield* projects.getById(caller.projectId).pipe(Effect.mapError(unavailable)); + const project = yield* projects.getById(thread.projectId).pipe(Effect.mapError(unavailable)); if (Option.isNone(project)) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -28,7 +31,7 @@ const handlers = { }); const git = yield* GitWorkflow.GitWorkflowService; return yield* git - .listRefs({ ...input, cwd: caller.worktreePath ?? project.value.workspaceRoot }) + .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot }) .pipe(Effect.mapError(unavailable)); }), t3_worktree_handoff: (input) => diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index d4cff97dbf75..373be5d3e46d 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -6,6 +6,7 @@ import { WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, WorktreeMcpStatusResult, + ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; @@ -23,7 +24,7 @@ const dependencies = [ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { description: - "Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.", + "Needs an agent running inside a T3 thread. Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.", parameters: WorktreeMcpHandoffInput, success: WorktreeMcpHandoffResult, failure: WorktreeMcpFailure, @@ -38,7 +39,7 @@ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { 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.", + "Needs an agent running inside a T3 thread. 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.", // 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 @@ -56,8 +57,9 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", { const WorktreeListTool = Tool.make("t3_worktree_list", { description: - "List branch refs and their associated checkout paths for this thread's workspace using the app's ref inventory. Detached worktrees without a branch are not included. Use t3_worktree_status for the thread binding and t3_worktree_handoff to create a new worktree.", + "List branch refs and their associated checkout paths for a thread's workspace (omit threadId for this thread) using the app's ref inventory. Detached worktrees without a branch are not included. Use t3_worktree_status for the thread binding and t3_worktree_handoff to create a new worktree.", parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), query: VcsListRefsInput.fields.query, cursor: VcsListRefsInput.fields.cursor, limit: VcsListRefsInput.fields.limit, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 696a40d8d176..0d44bac4c054 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1082,7 +1082,7 @@ it.effect( const token = captured?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(token); const resolved = yield* registry.resolve(token!); - assert.equal(resolved?.threadId, threadId); + assert.equal(resolved?.thread.threadId, threadId); assert.deepEqual( resolved?.capabilities, new Set(["preview", "orchestration", "worktree", "pull-requests"]), @@ -1317,7 +1317,7 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId); }); yield* effect.pipe( @@ -1393,7 +1393,7 @@ it.effect( McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId); }); yield* effect.pipe( @@ -1449,7 +1449,7 @@ it.effect( // process's MCP client keeps using the credential it was started with. yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!))?.thread.threadId, threadId, "detach must not revoke the credential the live provider process still holds", ); @@ -1468,7 +1468,7 @@ it.effect( original?.providerSessionId, "re-attach must reuse the existing credential, not rotate it", ); - assert.equal((yield* registry.resolve(originalToken!))?.threadId, threadId); + assert.equal((yield* registry.resolve(originalToken!))?.thread.threadId, threadId); // Releasing the session (provider process gone) still revokes. yield* manager.close(providerSessionId); @@ -1592,7 +1592,7 @@ it.effect( "the credential the adapter was configured with must remain current", ); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!))?.thread.threadId, threadId, "the predecessor release must not revoke a credential reserved by an in-flight open", ); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 55278b810e39..307bbb708b18 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -468,8 +468,8 @@ export const layerWithOptions = ( const resolved = yield* mcpSessionRegistry.resolve(rawToken); if ( resolved !== undefined && - resolved.threadId === threadId && - resolved.providerInstanceId === providerInstanceId && + resolved.thread.threadId === threadId && + resolved.thread.providerInstanceId === providerInstanceId && // A flipped browser-access setting must not survive through // credential reuse: rotate so the new scope reflects it. resolved.capabilities.has("preview") === browserToolsAvailable && diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 565f42866466..bed1e7a782e2 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -280,7 +280,15 @@ export const OrchestratorMcpThreadStatus = Schema.Union([ ]); export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type; +const OrchestratorMcpProjectTarget = Schema.optional( + ProjectId.annotate({ + description: + "Project to act on. Omit for the calling thread's project; required when the caller is not a T3 thread.", + }), +); + export const OrchestratorMcpThreadListInput = Schema.Struct({ + projectId: OrchestratorMcpProjectTarget, statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), ), @@ -316,7 +324,8 @@ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem export const OrchestratorMcpThreadListResult = Schema.Struct({ projectId: ProjectId, - currentThreadId: ThreadId, + /** The calling thread, or null when the caller is not a T3 thread. */ + currentThreadId: Schema.NullOr(ThreadId), threads: Schema.Array(OrchestratorMcpThreadListItem), nextCursor: Schema.NullOr(NonNegativeInt), total: NonNegativeInt, @@ -475,9 +484,11 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ - parentThreadId: ThreadId, - inheritedProviderInstanceId: ProviderInstanceId, - inheritedModel: Schema.String, + /** The calling thread, or null when the caller is not a T3 thread. */ + parentThreadId: Schema.NullOr(ThreadId), + /** The calling thread's selection, or null when the caller is not a T3 thread. */ + inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId), + inheritedModel: Schema.NullOr(Schema.String), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, providers: Schema.Array(OrchestratorMcpProviderCapability), @@ -495,6 +506,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type; export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ + projectId: OrchestratorMcpProjectTarget, prompt: OrchestratorMcpPrompt.annotate({ description: "Prompt executed on every scheduled run.", }), @@ -535,6 +547,17 @@ export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.T export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask; export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type; +export const OrchestratorMcpListScheduledTasksInput = Schema.Struct({ + projectId: Schema.optional( + ProjectId.annotate({ + description: + "Only list this project's tasks. Omit for the calling thread's project, or for every project when the caller is not a T3 thread.", + }), + ), +}); +export type OrchestratorMcpListScheduledTasksInput = + typeof OrchestratorMcpListScheduledTasksInput.Type; + export const OrchestratorMcpListScheduledTasksResult = Schema.Struct({ tasks: Schema.Array(OrchestratorMcpScheduledTask), }); @@ -583,6 +606,8 @@ export class OrchestratorMcpFailure extends Schema.TaggedError() "handoff_in_progress", "invalid_request", "operation_failed", + "thread_credential_required", ]), message: Schema.String, }, From c40873762863abe34e2e784c518d81cbc08f6662 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 4 Oct 2026 22:41:06 -0700 Subject: [PATCH 02/21] fix(desktop): V2 imports stashed prompts and drafts from the V1 profile (#15072) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/app/DesktopApp.ts | 5 + .../src/app/DesktopLegacyLocalStorage.ts | 117 +++++ apps/desktop/src/app/chromiumLocalStorage.ts | 460 ++++++++++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 6 + apps/desktop/src/ipc/channels.ts | 2 + .../src/ipc/methods/legacyLocalStorage.ts | 30 ++ .../src/legacyLocalStorageMerge.test.ts | 102 ++++ apps/desktop/src/legacyLocalStorageMerge.ts | 113 +++++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 13 + docs/user/thread-migration.md | 4 +- 11 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/DesktopLegacyLocalStorage.ts create mode 100644 apps/desktop/src/app/chromiumLocalStorage.ts create mode 100644 apps/desktop/src/ipc/methods/legacyLocalStorage.ts create mode 100644 apps/desktop/src/legacyLocalStorageMerge.test.ts create mode 100644 apps/desktop/src/legacyLocalStorageMerge.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 365363881f5b..d98f61d6fc00 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -19,6 +19,7 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; @@ -179,6 +180,10 @@ const bootstrap = Effect.gen(function* () { }); yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); + // Before any window: the preload merges these items before the app reads storage. + yield* (yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage).load( + yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath, + ); yield* snapShot.initialize; diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts new file mode 100644 index 000000000000..b29cfb3b7c63 --- /dev/null +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -0,0 +1,117 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +/** + * Carries the renderer's localStorage (prompt stash, unsent drafts, layout, + * theme) over from the V1 desktop profile, which V2 replaced with its own + * `t3code-v2` profile. The V1 profile is only read, never opened by Chromium, + * so this works while V1 is still running. + * + * `load` runs before the window opens; the preload takes the items once, + * merges them into the new profile, and calls `complete`, which writes a + * marker so later launches skip the read. A failed read or merge, or a window + * that never completes, leaves no marker and retries next launch. + */ +export class DesktopLegacyLocalStorage extends Context.Service< + DesktopLegacyLocalStorage, + { + readonly load: (userDataPath: string) => Effect.Effect; + readonly take: Effect.Effect>>>; + readonly complete: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopLegacyLocalStorage") {} + +const MARKER_FILE_NAME = "v1-local-storage-imported"; +// V1 used "T3 Code (Alpha)" when that folder existed and "t3code" otherwise. +const V1_PROFILE_NAMES = ["T3 Code (Alpha)", "t3code"]; + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const items = yield* Ref.make(Option.none>>()); + const markerPath = yield* Ref.make(Option.none()); + + /** Newest file mtime in a directory; appending to a log leaves the directory's own mtime alone. */ + const newestFileMtime = (directory: string) => + Effect.gen(function* () { + let newest = 0; + for (const name of yield* fs.readDirectory(directory)) { + const info = yield* fs.stat(path.join(directory, name)).pipe(Effect.option); + if (Option.isNone(info)) continue; + const mtime = Option.match(info.value.mtime, { + onNone: () => 0, + onSome: (date) => date.getTime(), + }); + newest = Math.max(newest, mtime); + } + return newest; + }).pipe(Effect.option); + + /** The V1 profile whose Local Storage was written most recently, if any. */ + const findV1LocalStorage = Effect.gen(function* () { + let newest: { readonly directory: string; readonly mtime: number } | null = null; + for (const name of V1_PROFILE_NAMES) { + const directory = path.join(environment.appDataDirectory, name, "Local Storage", "leveldb"); + const mtime = yield* newestFileMtime(directory); + if (Option.isNone(mtime)) continue; + if (newest === null || mtime.value > newest.mtime) newest = { directory, mtime: mtime.value }; + } + return newest?.directory ?? null; + }); + + const writeMarker = Effect.gen(function* () { + const marker = yield* Ref.get(markerPath); + if (Option.isNone(marker)) return; + yield* fs + .writeFileString(marker.value, "") + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not record the V1 Local Storage import", error), + ), + ); + }); + + const load = Effect.fn("desktop.legacyLocalStorage.load")(function* (userDataPath: string) { + // Development already shares its profile between versions. + if (environment.isDevelopment) return; + const marker = path.join(userDataPath, MARKER_FILE_NAME); + if (yield* fs.exists(marker).pipe(Effect.orElseSucceed(() => true))) return; + yield* Ref.set(markerPath, Option.some(marker)); + const directory = yield* findV1LocalStorage; + if (directory === null) return yield* writeMarker; + const origin = ElectronProtocol.getDesktopUrl(false).replace(/\/$/, ""); + const read = yield* readChromiumLocalStorage(directory, origin).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.tapError((error) => + Effect.logWarning("Could not read V1 Local Storage; will retry next launch", error), + ), + Effect.option, + ); + if (Option.isNone(read)) return; + yield* Effect.logInfo("V1 Local Storage ready to import", { + directory, + keys: read.value.size, + }); + yield* Ref.set(items, Option.some(Object.fromEntries(read.value))); + }); + + return DesktopLegacyLocalStorage.of({ + load, + // Taking clears the items, so a reload before `complete` cannot merge twice. + take: Ref.getAndSet(items, Option.none()), + complete: writeMarker, + }); +}); + +export const layer = Layer.effect(DesktopLegacyLocalStorage, make); diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts new file mode 100644 index 000000000000..d5847b814db6 --- /dev/null +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -0,0 +1,460 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +/** + * Read-only decoder for a Chromium profile's `Local Storage/leveldb`. + * + * It reads the database files directly and never takes LevelDB's lock, so it + * works while another app version has the profile open. Live files come from + * the MANIFEST that CURRENT names, and the newest sequence number wins, which + * is how LevelDB itself resolves overwrites and deletions. + */ + +const LOG_BLOCK_SIZE = 32 * 1024; +const LOG_HEADER_SIZE = 7; +const TABLE_FOOTER_SIZE = 48; +const BLOCK_TRAILER_SIZE = 5; + +class LevelDbFormatError extends Schema.TaggedError()("LevelDbFormatError", { + detail: Schema.String, +}) {} + +const isLevelDbFormatError = Schema.is(LevelDbFormatError); + +const fail = (detail: string): never => { + throw new LevelDbFormatError({ detail }); +}; + +// ignoreBOM keeps a leading U+FEFF that is part of the stored string. +const textDecoderUtf16 = new TextDecoder("utf-16le", { ignoreBOM: true }); +const textDecoderUtf8 = new TextDecoder(); + +/** True ISO-8859-1. WHATWG's "latin1" label is windows-1252, which remaps 0x80-0x9F. */ +function decodeLatin1(bytes: Uint8Array): string { + let result = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + result += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return result; +} + +class Reader { + readonly bytes: Uint8Array; + offset: number; + constructor(bytes: Uint8Array, offset = 0) { + this.bytes = bytes; + this.offset = offset; + } + get done() { + return this.offset >= this.bytes.length; + } + byte(): number { + if (this.offset >= this.bytes.length) fail("unexpected end of data"); + return this.bytes[this.offset++]!; + } + varint(): number { + let result = 0; + let multiplier = 1; + for (let shift = 0; shift < 64; shift += 7) { + const byte = this.byte(); + result += (byte & 0x7f) * multiplier; + if ((byte & 0x80) === 0) return result; + multiplier *= 128; + } + return fail("varint too long"); + } + take(length: number): Uint8Array { + if (length < 0 || this.offset + length > this.bytes.length) fail("length out of range"); + const slice = this.bytes.subarray(this.offset, this.offset + length); + this.offset += length; + return slice; + } + lengthPrefixed(): Uint8Array { + return this.take(this.varint()); + } +} + +const readUint32 = (bytes: Uint8Array, offset: number) => + (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)) + + bytes[offset + 3]! * 0x1000000; + +const CRC32C_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index++) { + let crc = index; + for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0x82f63b78 : crc >>> 1; + table[index] = crc >>> 0; + } + return table; +})(); + +function crc32c(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC32C_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +/** LevelDB stores CRCs masked so a CRC of data containing CRCs stays well distributed. */ +const unmaskCrc = (masked: number) => { + const rotated = (masked - 0xa282ead8) >>> 0; + return ((rotated >>> 17) | (rotated << 15)) >>> 0; +}; + +function decompressSnappy(input: Uint8Array): Uint8Array { + const reader = new Reader(input); + const output = new Uint8Array(reader.varint()); + let written = 0; + while (!reader.done) { + const tag = reader.byte(); + const kind = tag & 3; + if (kind === 0) { + let length = tag >>> 2; + if (length >= 60) { + const extraBytes = length - 59; + length = 0; + for (let index = 0; index < extraBytes; index++) length += reader.byte() * 256 ** index; + } + length += 1; + if (written + length > output.length) fail("snappy literal overflows output"); + output.set(reader.take(length), written); + written += length; + continue; + } + let length: number; + let offset: number; + if (kind === 1) { + length = 4 + ((tag >>> 2) & 7); + offset = ((tag >>> 5) << 8) | reader.byte(); + } else if (kind === 2) { + length = (tag >>> 2) + 1; + offset = reader.byte() | (reader.byte() << 8); + } else { + length = (tag >>> 2) + 1; + offset = readUint32(reader.take(4), 0); + } + if (offset === 0 || offset > written || written + length > output.length) { + fail("snappy copy out of range"); + } + // Copies may overlap their own output, so they go byte by byte. + for (let index = 0; index < length; index++) { + output[written] = output[written - offset]!; + written++; + } + } + if (written !== output.length) fail("snappy output length mismatch"); + return output; +} + +/** + * Splits a LevelDB log (write-ahead log or MANIFEST) into its records. Like + * LevelDB's own reader, a corrupt record or zero padding skips the rest of its + * block, since a reused log keeps appending after a torn write, and a record + * cut off by the end of the file ends the log. + */ +function readLogRecords(bytes: Uint8Array): Uint8Array[] { + const records: Uint8Array[] = []; + let pending: Uint8Array[] | null = null; + let offset = 0; + while (offset + LOG_HEADER_SIZE <= bytes.length) { + const blockRemaining = LOG_BLOCK_SIZE - (offset % LOG_BLOCK_SIZE); + if (blockRemaining < LOG_HEADER_SIZE) { + offset += blockRemaining; + continue; + } + const length = bytes[offset + 4]! | (bytes[offset + 5]! << 8); + const type = bytes[offset + 6]!; + const end = offset + LOG_HEADER_SIZE + length; + const fitsBlock = LOG_HEADER_SIZE + length <= blockRemaining; + if (fitsBlock && end > bytes.length) break; + const typeAndPayload = bytes.subarray(offset + 6, end); + if ( + type === 0 || + !fitsBlock || + crc32c(typeAndPayload) !== unmaskCrc(readUint32(bytes, offset)) + ) { + offset += blockRemaining; + pending = null; + continue; + } + const payload = typeAndPayload.subarray(1); + offset = end; + if (type === 1) { + records.push(payload); + pending = null; + } else if (type === 2) { + pending = [payload]; + } else if (type === 3 && pending) { + pending.push(payload); + } else if (type === 4 && pending) { + pending.push(payload); + const total = pending.reduce((sum, part) => sum + part.length, 0); + const record = new Uint8Array(total); + let position = 0; + for (const part of pending) { + record.set(part, position); + position += part.length; + } + records.push(record); + pending = null; + } else { + pending = null; + } + } + return records; +} + +interface LiveFiles { + readonly tables: ReadonlyArray; + readonly logNumber: number; + readonly previousLogNumber: number; +} + +/** Replays MANIFEST version edits to find the live table files and log. */ +function readManifest(bytes: Uint8Array): LiveFiles { + const tables = new Set(); + let logNumber = 0; + let previousLogNumber = 0; + // LevelDB refuses to open a MANIFEST missing any of these, and so do we. + const required = new Set([2, 3, 4]); + for (const record of readLogRecords(bytes)) { + const reader = new Reader(record); + while (!reader.done) { + const tag = reader.varint(); + required.delete(tag); + switch (tag) { + case 1: // comparator + reader.lengthPrefixed(); + break; + case 2: + logNumber = reader.varint(); + break; + case 3: // next file number + case 4: // last sequence + reader.varint(); + break; + case 5: // compaction pointer + reader.varint(); + reader.lengthPrefixed(); + break; + case 6: // deleted file + reader.varint(); + tables.delete(reader.varint()); + break; + case 7: { + reader.varint(); + tables.add(reader.varint()); + reader.varint(); + reader.lengthPrefixed(); + reader.lengthPrefixed(); + break; + } + case 9: + previousLogNumber = reader.varint(); + break; + default: + fail(`unknown manifest tag ${tag}`); + } + } + } + if (required.size > 0) fail("incomplete manifest"); + return { tables: [...tables], logNumber, previousLogNumber }; +} + +interface VersionedEntry { + readonly sequence: number; + /** `null` marks a deletion. */ + readonly value: Uint8Array | null; +} + +type EntryMap = Map; + +const keyId = decodeLatin1; + +function record(entries: EntryMap, key: Uint8Array, entry: VersionedEntry) { + const id = keyId(key); + const existing = entries.get(id); + if (!existing || existing.entry.sequence < entry.sequence) entries.set(id, { key, entry }); +} + +/** Applies one write-ahead log record (a WriteBatch). */ +function readWriteBatch(entries: EntryMap, batch: Uint8Array) { + if (batch.length < 12) fail("write batch too short"); + // The sequence is a little-endian uint64; real values stay far below 2^53. + const sequence = readUint32(batch, 0) + readUint32(batch, 4) * 0x100000000; + const count = readUint32(batch, 8); + const reader = new Reader(batch, 12); + for (let index = 0; index < count; index++) { + const type = reader.byte(); + const key = reader.lengthPrefixed(); + if (type === 1) { + record(entries, key, { sequence: sequence + index, value: reader.lengthPrefixed() }); + } else if (type === 0) { + record(entries, key, { sequence: sequence + index, value: null }); + } else { + fail(`unknown write batch record type ${type}`); + } + } +} + +function readBlock(table: Uint8Array, reader: Reader): Uint8Array { + const offset = reader.varint(); + const size = reader.varint(); + if (offset + size + BLOCK_TRAILER_SIZE > table.length) fail("block handle out of range"); + const contents = table.subarray(offset, offset + size); + const compression = table[offset + size]!; + const checksum = unmaskCrc(readUint32(table, offset + size + 1)); + if (crc32c(table.subarray(offset, offset + size + 1)) !== checksum) fail("block checksum"); + if (compression === 0) return contents; + if (compression === 1) return decompressSnappy(contents); + return fail(`unsupported block compression ${compression}`); +} + +function* blockEntries(block: Uint8Array): Generator<[Uint8Array, Uint8Array]> { + if (block.length < 4) fail("block too short"); + const restartCount = readUint32(block, block.length - 4); + const limit = block.length - 4 - restartCount * 4; + if (limit < 0) fail("restart array out of range"); + const reader = new Reader(block.subarray(0, limit)); + let key = new Uint8Array(0); + while (!reader.done) { + const shared = reader.varint(); + const unshared = reader.varint(); + const valueLength = reader.varint(); + if (shared > key.length) fail("block key prefix out of range"); + const nextKey = new Uint8Array(shared + unshared); + nextKey.set(key.subarray(0, shared)); + nextKey.set(reader.take(unshared), shared); + key = nextKey; + yield [key, reader.take(valueLength)]; + } +} + +/** Applies every entry in one sorted table (`.ldb`/`.sst`) file. */ +function readTable(entries: EntryMap, table: Uint8Array) { + if (table.length < TABLE_FOOTER_SIZE) fail("table too short"); + const footer = new Reader(table, table.length - TABLE_FOOTER_SIZE); + footer.varint(); + footer.varint(); + const index = readBlock(table, footer); + for (const [, handle] of blockEntries(index)) { + const block = readBlock(table, new Reader(handle)); + for (const [internalKey, value] of blockEntries(block)) { + if (internalKey.length < 8) fail("internal key too short"); + const trailerOffset = internalKey.length - 8; + const trailer = + readUint32(internalKey, trailerOffset) + + readUint32(internalKey, trailerOffset + 4) * 0x100000000; + const type = trailer % 256; + const sequence = Math.floor(trailer / 256); + const key = internalKey.subarray(0, trailerOffset); + record(entries, key, { sequence, value: type === 1 ? value : null }); + } + } +} + +/** Chromium prefixes each stored string with 0 (UTF-16LE) or 1 (Latin-1). */ +function decodeChromiumString(bytes: Uint8Array): string | null { + if (bytes.length === 0) return null; + const body = bytes.subarray(1); + if (bytes[0] === 1) return decodeLatin1(body); + if (bytes[0] === 0 && body.length % 2 === 0) return textDecoderUtf16.decode(body); + return null; +} + +export class ChromiumLocalStorageReadError extends Schema.TaggedError()( + "ChromiumLocalStorageReadError", + { + directory: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not read Chromium Local Storage at ${this.directory}.`; + } +} + +/** + * Returns the localStorage items one origin (for example `t3code://app`) had + * in the profile, or an empty map when the profile never stored any. + */ +export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage.read")(function* ( + directory: string, + origin: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const readFile = (name: string) => fs.readFile(path.join(directory, name)); + const wrap = Effect.mapError( + (cause: PlatformError.PlatformError | LevelDbFormatError) => + new ChromiumLocalStorageReadError({ directory, cause }), + ); + const decode = (run: () => A) => + Effect.try({ + try: run, + catch: (cause) => + isLevelDbFormatError(cause) ? cause : new LevelDbFormatError({ detail: String(cause) }), + }); + + const current = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); + if (!/^MANIFEST-\d+$/.test(current)) { + return yield* new ChromiumLocalStorageReadError({ + directory, + cause: new LevelDbFormatError({ detail: "bad CURRENT" }), + }); + } + const manifest = yield* readFile(current).pipe(wrap); + const live = yield* decode(() => readManifest(manifest)).pipe(wrap); + const logs = (yield* fs.readDirectory(directory).pipe(wrap)) + .map((name) => /^(\d+)\.log$/.exec(name)) + // The logs LevelDB's own recovery replays. + .filter((match) => { + const number = Number(match?.[1]); + return number >= live.logNumber || number === live.previousLogNumber; + }) + .map((match) => match![0]); + + const entries: EntryMap = new Map(); + for (const number of live.tables) { + const name = String(number).padStart(6, "0"); + // Newer LevelDB names tables .ldb; older databases may still hold .sst files. + const bytes = yield* readFile(`${name}.ldb`).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => readFile(`${name}.sst`), + ), + wrap, + ); + yield* decode(() => readTable(entries, bytes)).pipe(wrap); + } + for (const name of logs) { + const bytes = yield* readFile(name).pipe(wrap); + yield* decode(() => { + for (const batch of readLogRecords(bytes)) readWriteBatch(entries, batch); + }).pipe(wrap); + } + + // A flush while another process has the database open can move entries + // from a log we read into a table our MANIFEST copy predates. Fail so the + // caller retries rather than import a snapshot missing those entries. + const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); + const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap); + if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) { + return yield* new ChromiumLocalStorageReadError({ + directory, + cause: new LevelDbFormatError({ detail: "database changed while reading" }), + }); + } + + const prefix = `_${origin}\u0000`; + const items = new Map(); + for (const [id, { key: rawKey, entry }] of entries) { + if (!id.startsWith(prefix) || entry.value === null) continue; + const key = decodeChromiumString(rawKey.subarray(prefix.length)); + const value = decodeChromiumString(entry.value); + if (key !== null && value !== null) items.set(key, value); + } + return items; +}); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 4b43cd0eee96..a16535c4a5eb 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -70,6 +70,10 @@ import { } from "./methods/snapShot.ts"; import * as PreviewIpc from "./methods/preview.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; +import { + completeLegacyLocalStorage, + takeLegacyLocalStorage, +} from "./methods/legacyLocalStorage.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -80,6 +84,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(AppActivationIpc.setReady); yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handleSync(takeLegacyLocalStorage); + yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 151cd633d04e..098748dff124 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -119,3 +119,5 @@ export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input" export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; +export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; +export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; diff --git a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts new file mode 100644 index 000000000000..13f657e7812f --- /dev/null +++ b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts @@ -0,0 +1,30 @@ +import * as Electron from "electron"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopLegacyLocalStorage from "../../app/DesktopLegacyLocalStorage.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +export const takeLegacyLocalStorage = makeSyncIpcMethod({ + channel: IpcChannels.TAKE_LEGACY_LOCAL_STORAGE_CHANNEL, + result: Schema.NullOr(Schema.Record(Schema.String, Schema.String)), + handler: Effect.fn("desktop.ipc.legacyLocalStorage.take")(function* () { + const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; + return Option.getOrNull(yield* legacy.take); + }), +}); + +export const completeLegacyLocalStorage = makeIpcMethod({ + channel: IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.legacyLocalStorage.complete")(function* (_, event) { + const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; + // Chromium writes localStorage to disk lazily; persist the merge before + // the marker stops later launches from retrying it. + if (event) Electron.webContents.fromId(event.sender.id)?.session.flushStorageData(); + yield* legacy.complete; + }), +}); diff --git a/apps/desktop/src/legacyLocalStorageMerge.test.ts b/apps/desktop/src/legacyLocalStorageMerge.test.ts new file mode 100644 index 000000000000..fd10c1d02019 --- /dev/null +++ b/apps/desktop/src/legacyLocalStorageMerge.test.ts @@ -0,0 +1,102 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; + +const memoryStorage = (initial: Record = {}) => { + const items = new Map(Object.entries(initial)); + return { + items, + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => void items.set(key, value), + }; +}; + +const stash = (...ids: string[]) => + JSON.stringify({ + version: 2, + state: { entries: ids.map((id) => ({ id, prompt: `prompt ${id}` })) }, + }); + +const drafts = (version: number, threadKey: string, prompt: string) => + JSON.stringify({ + version, + state: { + draftsByThreadKey: { [threadKey]: { prompt } }, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }, + }); + +describe("mergeLegacyLocalStorage", () => { + it("copies V1 keys into an empty profile, except the per-install client id", () => { + const storage = memoryStorage(); + mergeLegacyLocalStorage(storage, { + "t3code:theme": "dark", + "t3code:prompt-stash:v2": stash("a"), + "t3.backgroundActivity.clientId": "v1-client", + }); + assert.equal(storage.getItem("t3code:theme"), "dark"); + assert.equal(storage.getItem("t3code:prompt-stash:v2"), stash("a")); + assert.equal(storage.getItem("t3.backgroundActivity.clientId"), null); + }); + + it("keeps V2 values for plain keys", () => { + const storage = memoryStorage({ "t3code:theme": "light" }); + mergeLegacyLocalStorage(storage, { "t3code:theme": "dark" }); + assert.equal(storage.getItem("t3code:theme"), "light"); + }); + + it("appends V1 stash entries after V2's without duplicating ids", () => { + const storage = memoryStorage({ "t3code:prompt-stash:v2": stash("new", "shared") }); + mergeLegacyLocalStorage(storage, { "t3code:prompt-stash:v2": stash("shared", "old") }); + const ids = JSON.parse(storage.getItem("t3code:prompt-stash:v2")!).state.entries.map( + (entry: { id: string }) => entry.id, + ); + assert.deepStrictEqual(ids, ["new", "shared", "old"]); + }); + + it("adds V1 drafts for other threads and keeps V2's draft for the same thread", () => { + const storage = memoryStorage({ + "t3code:composer-drafts:v1": drafts(9, "env:shared", "v2 text"), + }); + const legacy = JSON.parse(drafts(9, "env:shared", "v1 text")); + legacy.state.draftsByThreadKey["env:old"] = { prompt: "v1 only" }; + mergeLegacyLocalStorage(storage, { "t3code:composer-drafts:v1": JSON.stringify(legacy) }); + const merged = JSON.parse(storage.getItem("t3code:composer-drafts:v1")!); + assert.equal(merged.state.draftsByThreadKey["env:shared"].prompt, "v2 text"); + assert.equal(merged.state.draftsByThreadKey["env:old"].prompt, "v1 only"); + }); + + it("leaves V2 drafts alone when the stored shapes differ in version", () => { + const current = drafts(10, "env:a", "v2"); + const storage = memoryStorage({ "t3code:composer-drafts:v1": current }); + mergeLegacyLocalStorage(storage, { "t3code:composer-drafts:v1": drafts(9, "env:b", "v1") }); + assert.equal(storage.getItem("t3code:composer-drafts:v1"), current); + }); + + it("caps the merged stash at the store's entry limit, dropping the oldest V1 entries", () => { + const v2Ids = Array.from({ length: 15 }, (_, index) => `v2-${index}`); + const v1Ids = Array.from({ length: 10 }, (_, index) => `v1-${index}`); + const storage = memoryStorage({ "t3code:prompt-stash:v2": stash(...v2Ids) }); + mergeLegacyLocalStorage(storage, { "t3code:prompt-stash:v2": stash(...v1Ids) }); + const ids = JSON.parse(storage.getItem("t3code:prompt-stash:v2")!).state.entries.map( + (entry: { id: string }) => entry.id, + ); + assert.deepStrictEqual(ids, [...v2Ids, ...v1Ids.slice(0, 5)]); + }); + + it("keeps importing other keys after one write fails, and reports the failure", () => { + const storage = memoryStorage(); + const setItem = storage.setItem; + storage.setItem = (key, value) => { + if (key === "t3code:theme") throw new DOMException("full", "QuotaExceededError"); + setItem(key, value); + }; + const complete = mergeLegacyLocalStorage(storage, { + "t3code:theme": "dark", + "t3code:last-editor": "zed", + }); + assert.equal(storage.getItem("t3code:last-editor"), "zed"); + assert.isFalse(complete); + }); +}); diff --git a/apps/desktop/src/legacyLocalStorageMerge.ts b/apps/desktop/src/legacyLocalStorageMerge.ts new file mode 100644 index 000000000000..5a048e2edd18 --- /dev/null +++ b/apps/desktop/src/legacyLocalStorageMerge.ts @@ -0,0 +1,113 @@ +/** + * Merges V1 profile localStorage items into the V2 profile. Runs in the preload + * before the app reads storage, and never overwrites what V2 already holds: a + * key V2 has written keeps V2's value, except that stashed prompts and unsent + * drafts are merged entry by entry so neither side's are lost. + */ + +interface MergeStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +const PROMPT_STASH_KEY = "t3code:prompt-stash:v2"; +const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1"; +const NOT_IMPORTED_KEYS = new Set([ + // Identifies one running install to the server; two apps must not share it. + "t3.backgroundActivity.clientId", + // Resumes a permission setup V1 left mid-flow; V2 would redirect to it on boot. + "t3code:snap-shot-setup-resume:v1", +]); +/** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */ +const MAX_STASH_ENTRIES = 20; +const DRAFT_RECORD_FIELDS = [ + "draftsByThreadKey", + "draftThreadsByThreadKey", + "logicalProjectDraftThreadKeyByLogicalProjectKey", +] as const; + +type JsonObject = Record; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +function parseObject(raw: string): JsonObject | null { + try { + const parsed: unknown = JSON.parse(raw); + return isObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** V2 entries first, then V1 entries V2 does not already have, up to the stash cap. */ +function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | null { + const currentEntries = isObject(current.state) ? current.state.entries : null; + const legacyEntries = isObject(legacy.state) ? legacy.state.entries : null; + if (!Array.isArray(currentEntries) || !Array.isArray(legacyEntries)) return null; + if (current.version !== legacy.version) return null; + const ids = new Set(currentEntries.map((entry) => (isObject(entry) ? entry.id : undefined))); + const added = legacyEntries.filter((entry) => isObject(entry) && !ids.has(entry.id)); + const state = isObject(current.state) ? current.state : {}; + return { + ...current, + state: { ...state, entries: [...currentEntries, ...added].slice(0, MAX_STASH_ENTRIES) }, + }; +} + +/** Adds V1 drafts for threads V2 has no draft for. Shapes must share a version. */ +function mergeComposerDrafts(current: JsonObject, legacy: JsonObject): JsonObject | null { + if (current.version !== legacy.version) return null; + if (!isObject(current.state) || !isObject(legacy.state)) return null; + const state: JsonObject = { ...current.state }; + for (const field of DRAFT_RECORD_FIELDS) { + const currentRecord = state[field]; + const legacyRecord = legacy.state[field]; + if (!isObject(currentRecord) || !isObject(legacyRecord)) return null; + state[field] = { ...legacyRecord, ...currentRecord }; + } + return { ...current, state }; +} + +const isMergedKey = (key: string) => key === PROMPT_STASH_KEY || key === COMPOSER_DRAFTS_KEY; + +function mergeValue(key: string, current: string, legacy: string): string | null { + const merge = + key === PROMPT_STASH_KEY + ? mergePromptStash + : key === COMPOSER_DRAFTS_KEY + ? mergeComposerDrafts + : null; + if (merge === null) return null; + const currentObject = parseObject(current); + const legacyObject = parseObject(legacy); + if (currentObject === null || legacyObject === null) return null; + const merged = merge(currentObject, legacyObject); + return merged === null ? null : JSON.stringify(merged); +} + +/** Returns false when a write failed, so the caller can retry on a later launch. */ +export function mergeLegacyLocalStorage( + storage: MergeStorage, + legacyItems: Readonly>, +): boolean { + let complete = true; + // Stash and drafts first, so a full quota costs layout state rather than prompts. + const keys = Object.keys(legacyItems).sort( + (left, right) => Number(isMergedKey(right)) - Number(isMergedKey(left)), + ); + for (const key of keys) { + if (NOT_IMPORTED_KEYS.has(key)) continue; + const legacy = legacyItems[key]!; + const current = storage.getItem(key); + const next = current === null ? legacy : mergeValue(key, current, legacy); + if (next === null || next === current) continue; + try { + storage.setItem(key, next); + } catch { + // Over quota: skip this key rather than abandon the rest. + complete = false; + } + } + return complete; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2b095807fb5b..42a557bc84c8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,6 +60,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; +import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; @@ -140,6 +141,7 @@ const desktopFoundationLayer = Layer.mergeAll( MacPermissions.layer, DesktopState.layer, DesktopShutdown.layer, + DesktopLegacyLocalStorage.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e84a5821e9ff..7d7269974bcd 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,6 +10,7 @@ import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; +import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; const SNAP_SHOT_EVENT_TYPES = new Set([ "requested", @@ -30,6 +31,18 @@ function isSnapShotEvent(value: unknown): value is DesktopSnapShotEvent { exposeClerkBridge({ passkeys: true }); +// Runs before any app script reads localStorage. See DesktopLegacyLocalStorage. +try { + const legacyItems: unknown = ipcRenderer.sendSync(IpcChannels.TAKE_LEGACY_LOCAL_STORAGE_CHANNEL); + if (typeof legacyItems === "object" && legacyItems !== null) { + if (mergeLegacyLocalStorage(window.localStorage, legacyItems as Record)) { + void ipcRenderer.invoke(IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL); + } + } +} catch { + // Best effort: the app still starts on the V2 profile's own storage. +} + // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. const clientPlatform = process.platform; diff --git a/docs/user/thread-migration.md b/docs/user/thread-migration.md index f01488e1a56e..51f4b87ec9f0 100644 --- a/docs/user/thread-migration.md +++ b/docs/user/thread-migration.md @@ -10,7 +10,9 @@ and changes in either version do not sync to the other. Settings, attachments, a remain shared. The V2 desktop app uses a separate browser profile, so browser cookies and caches do not carry -over from V1. You may need to sign in again to websites opened inside the app. +over from V1. You may need to sign in again to websites opened inside the app. On its first launch, +the V2 desktop app copies stashed prompts, unsent drafts, layout, and theme from V1. Anything you +change in V2 afterwards stays in V2. The migrated thread keeps its title, project, provider and model selection, permission and interaction modes, branch or worktree, archive state, settlement state, snooze and pin state, and From 2a778f7a6e7b5f9403c5dbff2c2f4a9ce26c0ade Mon Sep 17 00:00:00 2001 From: scratchyone Date: Mon, 5 Oct 2026 01:46:13 -0400 Subject: [PATCH 03/21] feat(web): shell commands in the timeline are syntax highlighted (#15037) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Yash Singh --- apps/web/package.json | 1 + .../components/chat/HighlightedTokens.test.ts | 117 ++++ .../src/components/chat/HighlightedTokens.tsx | 204 +++++++ .../components/chat/MessagesTimeline.logic.ts | 2 +- .../src/components/chat/ShellCommandBlock.tsx | 76 +++ .../chat/SnapShotAttachmentDetails.tsx | 60 +- .../src/components/chat/V2ItemInspector.tsx | 3 +- apps/web/src/lib/embeddedScripts.test.ts | 124 ++++ apps/web/src/lib/embeddedScripts.ts | 544 ++++++++++++++++++ .../src/work-log/commandLabel.test.ts | 110 +++- .../src/work-log/commandLabel.ts | 77 ++- pnpm-lock.yaml | 9 + 12 files changed, 1266 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/chat/HighlightedTokens.test.ts create mode 100644 apps/web/src/components/chat/HighlightedTokens.tsx create mode 100644 apps/web/src/components/chat/ShellCommandBlock.tsx create mode 100644 apps/web/src/lib/embeddedScripts.test.ts create mode 100644 apps/web/src/lib/embeddedScripts.ts diff --git a/apps/web/package.json b/apps/web/package.json index ffccf206f910..53c53c1c7cf9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,6 +60,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", + "unbash": "^5.0.0", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/chat/HighlightedTokens.test.ts b/apps/web/src/components/chat/HighlightedTokens.test.ts new file mode 100644 index 000000000000..ef4bf456585f --- /dev/null +++ b/apps/web/src/components/chat/HighlightedTokens.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { embeddedScripts } from "../../lib/embeddedScripts"; +import { keyedLines, withEmbeddedScripts, wordsOf } from "./HighlightedTokens"; + +function tokensOf(...contents: string[]) { + let offset = 0; + return contents.map((content) => { + const token = { content, offset }; + offset += content.length; + return token; + }); +} + +function shape(parts: ReturnType) { + return parts.map((part) => + typeof part === "string" ? part : part.pieces.map((piece) => piece.text).join(""), + ); +} + +describe("wordsOf", () => { + it.each([ + // Shiki splits these shell words into several tokens. + [["echo", " ", '"', "$HOME", "/${", "PATH", "%%:*", '}"'], 'echo "$HOME/${PATH%%:*}"'], + [["rsync", " ", "-a", " ", "--exclude", " ", "build"], "rsync -a --exclude build"], + // A token can carry whitespace on either side or inside a string. + [["git", " && ", "echo", " ", '"two words"', "; "], 'git && echo "two words"; '], + [[" ", "indented"], " indented"], + [[], ""], + ])("wraps the same words as the plain-text fallback: %j", (contents, line) => { + expect(shape(wordsOf(tokensOf(...contents)))).toEqual( + line.split(/(\s+)/u).filter((part) => part !== ""), + ); + }); + + it("keeps each piece's own token so colors survive regrouping", () => { + const [word] = wordsOf(tokensOf('"', "$HOME", '"')); + expect( + typeof word === "string" ? null : word?.pieces.map((piece) => piece.token.content), + ).toEqual(['"', "$HOME", '"']); + }); +}); + +describe("keyedLines", () => { + it.each([ + "git status\ngit diff", + "Get-ChildItem\r\nWrite-Output done\r\n", + "mixed\r\nendings\n\nlone \r stays", + ])("renders the same text as the source: %j", (code) => { + // Shiki splits lines on \r?\n and drops the ending from the tokens. + const lines = code.split(/\r?\n/u).map((line) => tokensOf(line)); + const rendered = keyedLines(code, lines) + .map(({ tokens, ending }) => tokens.map((token) => token.content).join("") + ending) + .join(""); + expect(rendered).toBe(code); + }); + + it("keys empty lines apart", () => { + const code = "a\r\n\r\n\r\nb"; + const keys = keyedLines( + code, + code.split(/\r?\n/u).map((line) => tokensOf(line)), + ).map((line) => line.key); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("withEmbeddedScripts", () => { + // A stand-in grammar: each word is one token colored with the language name, + // and lines split the way Shiki splits them. + function tokenize(text: string, language: string) { + if (language === "unknown") throw new Error("Language not found"); + let lineStart = 0; + return text.split(/\r?\n/u).map((line) => { + const tokens = [...line.matchAll(/\S+|\s+/gu)].map((match) => ({ + content: match[0], + offset: lineStart + match.index, + color: language, + })); + lineStart += + line.length + (text.slice(lineStart + line.length).match(/^\r?\n/u)?.[0].length ?? 0); + return tokens; + }); + } + + function colorsOf(code: string, scripts = embeddedScripts(code)) { + const lines = withEmbeddedScripts(code, tokenize(code, "shellscript"), scripts, tokenize); + expect(lines.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe( + code.replaceAll("\r\n", "\n"), + ); + return lines.flat().map((token) => [token.content, token.color]); + } + + it("colors each nested script with its own grammar, keeping the text", () => { + expect(colorsOf(`bash -lc "python3 -c 'print(1)'"`)).toEqual([ + [`bash -lc "python3 -c '`, "shellscript"], + ["print(1)", "python"], + [`'"`, "shellscript"], + ]); + }); + + it("colors an escape sequence like the character it spells", () => { + expect(colorsOf(`bash -c "python3 -c 'print(\\"x\\")'"`)).toContainEqual([ + `print(\\"x\\")`, + "python", + ]); + }); + + it("keeps the surrounding colors where a grammar is missing", () => { + const code = "cat > x.ts <<'EOF'\r\nbody\r\nEOF"; + const [script] = embeddedScripts(code); + // Compare per character: untouched lines keep their grammar's token boundaries. + const characterColors = (scripts: ReturnType) => + colorsOf(code, scripts).flatMap(([content, color]) => [...content!].map(() => color)); + expect(characterColors([{ ...script!, language: "unknown" }])).toEqual(characterColors([])); + }); +}); diff --git a/apps/web/src/components/chat/HighlightedTokens.tsx b/apps/web/src/components/chat/HighlightedTokens.tsx new file mode 100644 index 000000000000..1d7f1522383f --- /dev/null +++ b/apps/web/src/components/chat/HighlightedTokens.tsx @@ -0,0 +1,204 @@ +import { use, useMemo, type CSSProperties } from "react"; + +import { resolveDiffThemeName } from "../../lib/diffRendering"; +import type { EmbeddedScript } from "../../lib/embeddedScripts"; +import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; + +interface SyntaxToken { + readonly content: string; + readonly offset: number; + readonly color?: string; + readonly fontStyle?: number; +} + +type TokenLines = ReadonlyArray>; + +const NO_EMBEDDED_SCRIPTS: ReadonlyArray = []; + +function syntaxTokenStyle(token: SyntaxToken): CSSProperties { + const fontStyle = token.fontStyle ?? 0; + return { + ...(token.color ? { color: token.color } : {}), + ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), + ...(fontStyle & 2 ? { fontWeight: 700 } : {}), + ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), + }; +} + +/** + * Colors `code` inside the caller's `
` without changing its text, so
+ * selection and copy match the plain version. Suspends while the grammar
+ * loads; wrap it in Suspense with the plain text as the fallback.
+ * `wordClassName` goes on each whitespace-separated word. `embedded`
+ * scripts are colored with their own grammar inside `code`.
+ */
+export function HighlightedTokens({
+  code,
+  language,
+  embedded = NO_EMBEDDED_SCRIPTS,
+  theme,
+  wordClassName,
+}: {
+  code: string;
+  language: string;
+  embedded?: ReadonlyArray;
+  theme: "light" | "dark";
+  wordClassName?: string;
+}) {
+  const highlighter = use(getSyntaxHighlighterPromise(language));
+  // Every grammar loads into the same shared highlighter.
+  for (const script of embedded) use(getSyntaxHighlighterPromise(script.language));
+  const lines = useMemo(() => {
+    const themeName = resolveDiffThemeName(theme);
+    const tokenize = (text: string, lang: string) =>
+      highlighter.codeToTokens(text, { lang, theme: themeName }).tokens;
+    return keyedLines(
+      code,
+      withEmbeddedScripts(code, tokenize(code, language), embedded, tokenize),
+    );
+  }, [code, embedded, highlighter, language, theme]);
+
+  return lines.map(({ key, tokens, ending }) => (
+    
+      {wordClassName
+        ? wordsOf(tokens).map((part) =>
+            typeof part === "string" ? (
+              part
+            ) : (
+              
+                {part.pieces.map((piece) => (
+                  
+                    {piece.text}
+                  
+                ))}
+              
+            ),
+          )
+        : tokens.map((token) => (
+            
+              {token.content}
+            
+          ))}
+      {ending}
+    
+  ));
+}
+
+/**
+ * Recolors each embedded script's span of `code` with its own grammar,
+ * outermost first so nested scripts win. A script whose grammar is missing
+ * keeps the colors around it.
+ */
+export function withEmbeddedScripts(
+  code: string,
+  lines: TokenLines,
+  embedded: ReadonlyArray,
+  tokenize: (text: string, language: string) => TokenLines,
+): TokenLines {
+  if (embedded.length === 0) return lines;
+  const styles = Array.from({ length: code.length });
+  for (const token of lines.flat()) {
+    styles.fill(token, token.offset, token.offset + token.content.length);
+  }
+  for (const script of embedded) {
+    let scriptLines: TokenLines;
+    try {
+      scriptLines = tokenize(script.text, script.language);
+    } catch {
+      continue;
+    }
+    for (const token of scriptLines.flat()) {
+      if (token.content === "") continue;
+      const last = token.offset + token.content.length - 1;
+      styles.fill(token, script.starts[token.offset], script.ends[last]);
+    }
+  }
+  return restyledLines(code, styles);
+}
+
+/** Splits `code` into lines of tokens, one per run of characters sharing a style. */
+function restyledLines(
+  code: string,
+  styles: ReadonlyArray,
+): SyntaxToken[][] {
+  const lines: SyntaxToken[][] = [];
+  let start = 0;
+  for (const lineBreak of [...code.matchAll(/\r?\n/gu), undefined]) {
+    const end = lineBreak?.index ?? code.length;
+    const tokens: SyntaxToken[] = [];
+    for (let index = start; index < end;) {
+      const style = styles[index];
+      let next = index + 1;
+      while (
+        next < end &&
+        styles[next]?.color === style?.color &&
+        styles[next]?.fontStyle === style?.fontStyle
+      ) {
+        next += 1;
+      }
+      tokens.push({
+        content: code.slice(index, next),
+        offset: index,
+        ...(style?.color ? { color: style.color } : {}),
+        ...(style?.fontStyle ? { fontStyle: style.fontStyle } : {}),
+      });
+      index = next;
+    }
+    lines.push(tokens);
+    if (lineBreak) start = lineBreak.index + lineBreak[0].length;
+  }
+  return lines;
+}
+
+/**
+ * Keys each line by its start offset, which stays unique when several lines
+ * are empty, and pairs it with the line ending `code` has after it. Shiki drops
+ * `\n` and `\r\n` from tokens alike, so CRLF has to come back from the source.
+ */
+export function keyedLines(
+  code: string,
+  lines: ReadonlyArray>,
+) {
+  const endings = code.match(/\r?\n/gu) ?? [];
+  let lineStart = 0;
+  return lines.map((tokens, index) => {
+    const text = tokens.map((token) => token.content).join("");
+    const key = `${lineStart}:${text}`;
+    const ending = endings[index] ?? "";
+    lineStart += text.length + ending.length;
+    return { key, tokens, ending };
+  });
+}
+
+interface Word {
+  readonly key: string;
+  readonly pieces: Array<{ key: string; text: string; token: SyntaxToken }>;
+}
+
+/**
+ * Regroups a line's tokens into whitespace runs and whole words, matching
+ * `text.split(/(\s+)/)`, so a word made of several tokens (`"$HOME/x"`) wraps
+ * exactly like its plain-text fallback.
+ */
+export function wordsOf(tokens: ReadonlyArray): Array {
+  const parts: Array = [];
+  let word: Word | null = null;
+  for (const token of tokens) {
+    let offset = token.offset;
+    for (const text of token.content.split(/(\s+)/u)) {
+      if (text === "") continue;
+      if (/^\s/u.test(text)) {
+        parts.push(text);
+        word = null;
+      } else {
+        if (!word) {
+          word = { key: String(offset), pieces: [] };
+          parts.push(word);
+        }
+        word.pieces.push({ key: String(offset), text, token });
+      }
+      offset += text.length;
+    }
+  }
+  return parts;
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 280cc3672103..81b4af628630 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -121,7 +121,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
   }
   const toolPresentation = resolveWorkEntryToolPresentation(entry);
   if (toolPresentation) return toolPresentation.displayName;
-  if (entry.command) return commandDisplayText(entry.command);
+  if (entry.command?.trim()) return commandDisplayText(entry.command);
   const action = toolGroupAction(entry);
   if (action === "code-search" || action === "search") {
     // Adapters title file searches with their target; the item keeps only the pattern.
diff --git a/apps/web/src/components/chat/ShellCommandBlock.tsx b/apps/web/src/components/chat/ShellCommandBlock.tsx
new file mode 100644
index 000000000000..cd264eaaa903
--- /dev/null
+++ b/apps/web/src/components/chat/ShellCommandBlock.tsx
@@ -0,0 +1,76 @@
+import {
+  commandHighlightLanguage,
+  withVisibleControlCharacters,
+} from "@t3tools/client-runtime/work-log/command-label";
+import { Suspense, use, useMemo } from "react";
+
+import { useTheme } from "../../hooks/useTheme";
+import { RenderErrorBoundary } from "../RenderErrorBoundary";
+import { HighlightedTokens } from "./HighlightedTokens";
+
+// Shell words wrap as a unit, so `--exclude` or a quoted string is not split
+// at a hyphen; a word longer than the line still breaks anywhere.
+const WORD_CLASS_NAME = "inline-block max-w-full [overflow-wrap:anywhere]";
+
+// The shell parser loads with the first expanded command, not with the timeline.
+let embeddedScriptsModule: Promise | undefined;
+function loadEmbeddedScripts() {
+  embeddedScriptsModule ??= import("../../lib/embeddedScripts");
+  return embeddedScriptsModule;
+}
+
+/** Same layout as the highlighted version, so the grammar arriving never reflows the block. */
+function PlainWords({ code }: { code: string }) {
+  // split with a capture group alternates words (even) and whitespace (odd).
+  return code.split(/(\s+)/u).map((part, index) =>
+    index % 2 === 1 || part === "" ? (
+      part
+    ) : (
+      // Positions are stable for a given string, which is all this renders.
+      // oxlint-disable-next-line react/no-array-index-key
+      
+        {part}
+      
+    ),
+  );
+}
+
+function HighlightedCommand({ code, theme }: { code: string; theme: "light" | "dark" }) {
+  const { embeddedScripts } = use(loadEmbeddedScripts());
+  const language = commandHighlightLanguage(code);
+  // Only shell syntax nests scripts; PowerShell's own grammar colors its strings.
+  const embedded = useMemo(
+    () => (language === "shellscript" ? embeddedScripts(code) : []),
+    [code, embeddedScripts, language],
+  );
+  return (
+    
+  );
+}
+
+/**
+ * The command a command_execution item ran, syntax highlighted. Scripts inside
+ * it, such as a `bash -lc` script or a Python heredoc, get their own grammar.
+ */
+export function ShellCommandBlock({ command }: { command: string }) {
+  const { resolvedTheme } = useTheme();
+  const code = withVisibleControlCharacters(command.trim());
+  if (!code) return null;
+  const plain = ;
+  return (
+    // The tool body sets the monospace, pre-wrapped text this sits in.
+    
+ + + + + +
+ ); +} diff --git a/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx b/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx index 793da952c6ef..ca6e366fe829 100644 --- a/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx +++ b/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx @@ -1,15 +1,14 @@ import type { SnapShotSource } from "@t3tools/contracts"; import { ImageIcon, TextIcon } from "lucide-react"; -import { Suspense, use, useMemo, type CSSProperties } from "react"; +import { Suspense } from "react"; import { useTheme } from "../../hooks/useTheme"; -import { resolveDiffThemeName } from "../../lib/diffRendering"; -import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; import { cn } from "../../lib/utils"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { HighlightedTokens } from "./HighlightedTokens"; export const SNAP_SHOT_ATTACHMENT_FRAME_CLASS = "relative h-28 w-52 max-w-full overflow-hidden rounded-lg border border-border/80"; @@ -19,59 +18,6 @@ export interface SnapShotAccessibilityDetails { format: "json" | "text"; } -interface SyntaxToken { - readonly content: string; - readonly offset: number; - readonly color?: string; - readonly fontStyle?: number; -} - -function syntaxTokenStyle(token: SyntaxToken): CSSProperties { - const fontStyle = token.fontStyle ?? 0; - return { - ...(token.color ? { color: token.color } : {}), - ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), - ...(fontStyle & 2 ? { fontWeight: 700 } : {}), - ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), - }; -} - -function HighlightedAccessibilityJson({ - content, - theme, -}: { - content: string; - theme: "light" | "dark"; -}) { - const highlighter = use(getSyntaxHighlighterPromise("json")); - const lines = useMemo( - () => - highlighter.codeToTokens(content, { - lang: "json", - theme: resolveDiffThemeName(theme), - }).tokens, - [content, highlighter, theme], - ); - - let lineOffset = 0; - return lines.map((line) => { - const lineContent = line.map((token) => token.content).join(""); - const lineKey = `${lineOffset}:${lineContent}`; - const hasNextLine = lineOffset + lineContent.length < content.length; - lineOffset += lineContent.length + 1; - return ( - - {line.map((token) => ( - - {token.content} - - ))} - {hasNextLine ? "\n" : null} - - ); - }); -} - export function SnapShotAccessibilityData({ details, className, @@ -84,7 +30,7 @@ export function SnapShotAccessibilityData({ details.format === "json" ? ( - + ) : ( diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 0b8275065549..7dc48a9e00a4 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -24,6 +24,7 @@ import { Button } from "../ui/button"; import ChatMarkdown from "../ChatMarkdown"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; +import { ShellCommandBlock } from "./ShellCommandBlock"; interface V2ItemInspectorProps { readonly projectedItem: OrchestrationV2ProjectedTurnItem; @@ -173,7 +174,7 @@ function ToolCallBody( const call = toolCallLines({ command: props.command, args: props.args }); return (
- {call.command ?
{call.command}
: null} + {call.command ? : null} {call.args ? (
{call.args.map(([key, value]) => ( diff --git a/apps/web/src/lib/embeddedScripts.test.ts b/apps/web/src/lib/embeddedScripts.test.ts new file mode 100644 index 000000000000..d99263b6479a --- /dev/null +++ b/apps/web/src/lib/embeddedScripts.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { embeddedScripts } from "./embeddedScripts"; + +/** Each script's language, its text, and the part of the command that spells it. */ +function scriptsOf(command: string) { + return embeddedScripts(command).map((script) => { + // Every character maps inside the command, in order. + script.starts.forEach((start, index) => { + expect(start).toBeLessThan(script.ends[index]!); + if (index > 0) expect(start).toBeGreaterThanOrEqual(script.ends[index - 1]!); + }); + return [ + script.language, + script.text, + command.slice(script.starts[0], script.ends.at(-1)), + ] as const; + }); +} + +const pythonScript = `from pathlib import Path +import json + +# print every package.json name and version +for p in sorted(Path('.').glob('**/package.json')): + data = json.loads(p.read_text()) + print(f"{data['name']:16} {data.get('version', '?')}") +`; + +describe("embeddedScripts", () => { + it("finds the script inside a Codex login-shell wrapper and the script inside that", () => { + // Recorded in the turn_interrupt_mid_tool Codex transcript + // (apps/server/src/orchestration-v2/testkit/fixtures). + const inner = `console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)`; + const command = `/bin/bash -lc "node -e \\"${inner}\\""`; + expect(scriptsOf(command)).toEqual([ + ["shellscript", `node -e "${inner}"`, `node -e \\"${inner}\\"`], + ["javascript", inner, inner], + ]); + }); + + it("highlights a heredoc fed to an interpreter in that interpreter's language", () => { + const command = `python3 - <<'PY'\n${pythonScript}PY`; + expect(scriptsOf(command)).toEqual([["python", pythonScript, pythonScript]]); + }); + + it("maps escaped characters back to both source characters", () => { + const command = `/bin/zsh -lc "python3 - <<'PY'\nprint(\\"\\$HOME\\")\nPY"`; + const [shell, python] = scriptsOf(command); + expect(shell?.[1]).toBe(`python3 - <<'PY'\nprint("$HOME")\nPY`); + expect(python).toEqual(["python", `print("$HOME")\n`, `print(\\"\\$HOME\\")\n`]); + }); + + it.each([ + [`python3 -c 'print(1)'`, "python", "print(1)"], + [`python3 -X dev -c 'print(1)'`, "python", "print(1)"], + [`py -3 -c 'print(1)'`, "python", "print(1)"], + [`C:\\Python312\\python.exe -c 'print(1)'`, "python", "print(1)"], + [`"C:\\Program Files\\Python312\\python.exe" -c 'print(1)'`, "python", "print(1)"], + [`node --eval "process.exit(0)"`, "javascript", "process.exit(0)"], + [`bun -e 'console.log(Bun.version)'`, "typescript", "console.log(Bun.version)"], + [`deno eval 'console.log(1)'`, "typescript", "console.log(1)"], + [`deno eval --config deno.json 'console.log(1)'`, "typescript", "console.log(1)"], + [`deno eval --env-file 'console.log(1)'`, "typescript", "console.log(1)"], + [`psql -f schema.sql -c 'select 1'`, "sql", "select 1"], + [`node -C development -e 'console.log(1)'`, "javascript", "console.log(1)"], + [`ruby -e 'puts 1'`, "ruby", "puts 1"], + [`ruby -I lib -e 'puts 1'`, "ruby", "puts 1"], + [`perl -E 'say 1'`, "perl", "say 1"], + [`sqlite3 app.db "select * from users"`, "sql", "select * from users"], + [`psql -d app -c 'select 1'`, "sql", "select 1"], + [`pwsh -NoProfile -Command "Get-ChildItem"`, "powershell", "Get-ChildItem"], + [`env FOO=1 sudo -u bob python3 -c 'print(1)'`, "python", "print(1)"], + [`env -- FOO=1 python3 -c 'print(1)'`, "python", "print(1)"], + [`node --eval="process.exit(0)"`, "javascript", "process.exit(0)"], + [`psql --command='select 1'`, "sql", "select 1"], + [`mysql -c -e 'select 1'`, "sql", "select 1"], + [`mysql -f app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], + [`psql app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], + [`echo "$(node -e 'console.log(1)')"`, "javascript", "console.log(1)"], + [`python3 <<< 'print(1)'`, "python", "print(1)"], + ])("finds the inline script in %s", (command, language, script) => { + expect(scriptsOf(command)).toEqual([[language, script, expect.any(String)]]); + }); + + it.each([[`psql -c 'select 1' -c 'select 2'`], [`sqlite3 app.db "select 1" "select 2"`]])( + "finds every statement a SQL shell runs: %s", + (command) => { + expect(scriptsOf(command).map(([language, text]) => [language, text])).toEqual([ + ["sql", "select 1"], + ["sql", "select 2"], + ]); + }, + ); + + it.each([ + ["the file `cat` writes", `cat > src/app.ts <<'EOF'\nconst a = 1;\nEOF`, "typescript"], + ["the file `tee` writes", `tee -a config.json < { + expect(scriptsOf(command).map(([lang]) => lang)).toEqual([language]); + }); + + it.each([ + ["a script file", `python3 build.py <log.ts <<'EOF'\nx\nEOF`], + ])("finds nothing for %s", (_case, command) => { + expect(scriptsOf(command)).toEqual([]); + }); + + it("trusts nothing in a command whose quoting does not parse", () => { + // Codex's secret redaction dropped the backslash of an escaped quote, so + // the -lc argument ends early and the rest no longer balances. + const command = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`; + expect(scriptsOf(command)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/embeddedScripts.ts b/apps/web/src/lib/embeddedScripts.ts new file mode 100644 index 000000000000..809481b6973f --- /dev/null +++ b/apps/web/src/lib/embeddedScripts.ts @@ -0,0 +1,544 @@ +import { getFiletypeFromFileName } from "@pierre/diffs"; +import { + parse, + type Command, + type DoubleQuotedChild, + type HereDoc, + type Word, + type WordPart, +} from "unbash"; + +/** + * Code in another language inside a shell command: a `bash -lc "…"` script, a + * `python3 -c '…'` argument, or a heredoc fed to an interpreter or written to + * a file. `starts[i]` and `ends[i]` are the span of the original command that + * spells `text[i]`, so a quoted `\"` maps back to both of its characters. + */ +export interface EmbeddedScript { + readonly language: string; + readonly text: string; + readonly starts: ReadonlyArray; + readonly ends: ReadonlyArray; +} + +interface MappedText { + readonly text: string; + readonly starts: ReadonlyArray; + readonly ends: ReadonlyArray; +} + +interface Interpreter { + readonly language: string; + /** Flags whose next argument is the script. */ + readonly inline: (flag: string) => boolean; + /** Options that consume the next argument. */ + readonly valueOptions?: ReadonlySet; + /** Options after which the script comes from a file or module instead. */ + readonly scriptFileOptions?: ReadonlySet; + /** "always" for SQL shells, which read statements from stdin next to their database argument. */ + readonly stdin: "always" | "without-script-file" | "never"; + /** The first positional argument holding a script, as in `sqlite3 app.db "select 1"`. */ + readonly positionalScript?: number; + /** SQL shells run every `-c` and every statement argument, in order. */ + readonly repeatable?: boolean; + readonly subcommand?: string; + /** PowerShell matches parameters case-insensitively; the lists above are lowercase. */ + readonly ignoreCase?: boolean; +} + +const MAX_DEPTH = 4; + +const flagIn = + (...flags: string[]) => + (flag: string) => + flags.includes(flag); + +const SHELL: Interpreter = { + language: "shellscript", + inline: (flag) => /^-[a-z]*c[a-z]*$/u.test(flag), + valueOptions: new Set(["-o", "-O", "--rcfile", "--init-file"]), + stdin: "without-script-file", +}; + +const PYTHON: Interpreter = { + language: "python", + inline: flagIn("-c"), + valueOptions: new Set(["-W", "-X"]), + scriptFileOptions: new Set(["-m"]), + stdin: "without-script-file", +}; + +// Shared by SQL clients; each names its own script flags, since `-c` and `-f` +// mean different things to psql and mysql. +const SQL_SHELL = { + language: "sql", + valueOptions: new Set([ + "-d", + "-h", + "-u", + "-U", + "--dbname", + "--host", + "--user", + "--username", + "-separator", + "-nullvalue", + "-init", + ]), + stdin: "always", + repeatable: true, +} satisfies Omit; + +const INTERPRETERS: Record = { + sh: SHELL, + bash: SHELL, + zsh: SHELL, + dash: SHELL, + ash: SHELL, + ksh: SHELL, + fish: { language: "fish", inline: flagIn("-c", "--command"), stdin: "without-script-file" }, + node: { + language: "javascript", + inline: flagIn("-e", "--eval", "-p", "--print"), + valueOptions: new Set(["-r", "--require", "--import", "--loader", "-C", "--conditions"]), + stdin: "without-script-file", + }, + bun: { language: "typescript", inline: flagIn("-e", "--eval", "-p", "--print"), stdin: "never" }, + deno: { + language: "typescript", + inline: () => false, + // `--env-file` takes its optional path only as `--env-file=path`. + valueOptions: new Set(["-c", "--config", "--import-map", "--ext"]), + stdin: "never", + subcommand: "eval", + }, + ruby: { + language: "ruby", + inline: flagIn("-e"), + valueOptions: new Set(["-r", "-I", "-C"]), + stdin: "without-script-file", + }, + perl: { language: "perl", inline: flagIn("-e", "-E"), stdin: "without-script-file" }, + osascript: { + language: "applescript", + inline: flagIn("-e"), + valueOptions: new Set(["-l", "-s"]), + stdin: "without-script-file", + }, + pwsh: { + language: "powershell", + inline: flagIn("-c", "-command"), + valueOptions: new Set(["-executionpolicy", "-workingdirectory", "-windowstyle"]), + scriptFileOptions: new Set(["-f", "-file", "-encodedcommand"]), + stdin: "never", + ignoreCase: true, + }, + psql: { + ...SQL_SHELL, + inline: flagIn("-c", "--command"), + scriptFileOptions: new Set(["-f", "--file"]), + }, + mysql: { ...SQL_SHELL, inline: flagIn("-e", "--execute") }, + sqlite3: { ...SQL_SHELL, inline: flagIn("-cmd"), positionalScript: 1 }, + duckdb: { + ...SQL_SHELL, + inline: flagIn("-c", "-cmd"), + scriptFileOptions: new Set(["-f"]), + positionalScript: 1, + }, +}; +INTERPRETERS.powershell = INTERPRETERS.pwsh!; +INTERPRETERS.mariadb = INTERPRETERS.mysql!; + +// Commands that run the next word as the program, with the options that consume a value. +const PROGRAM_WRAPPERS: Record> = { + env: new Set(["-u", "--unset", "-C", "--chdir"]), + sudo: new Set(["-u", "--user", "-g", "--group", "-C", "-D", "--chdir"]), + nice: new Set(["-n", "--adjustment"]), + nohup: new Set(), + exec: new Set(["-a"]), + command: new Set(), + builtin: new Set(), +}; + +// Heredoc delimiters that name their language, as in `<<'SQL'`. +const DELIMITER_LANGUAGES: Record = { + py: "python", + python: "python", + js: "javascript", + javascript: "javascript", + ts: "typescript", + typescript: "typescript", + sql: "sql", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + sh: "shellscript", + bash: "shellscript", + shell: "shellscript", + rb: "ruby", + ruby: "ruby", + html: "html", + css: "css", + md: "markdown", + markdown: "markdown", + xml: "xml", + diff: "diff", + patch: "diff", +}; + +/** + * The scripts embedded in a shell command, outermost first, so a highlighter + * painting them in order lets a nested script override the one containing it. + * Returns nothing for a command that does not parse cleanly; where a broken + * quote ends cannot be trusted. + */ +export function embeddedScripts(command: string): EmbeddedScript[] { + const identity = Array.from({ length: command.length }, (_, index) => index); + return scriptsIn( + { text: command, starts: identity, ends: identity.map((index) => index + 1) }, + 0, + ); +} + +function scriptsIn(source: MappedText, depth: number): EmbeddedScript[] { + const script = parse(source.text); + if (script.errors?.length) return []; + const found: EmbeddedScript[] = []; + forEachCommand(script.commands, (command) => { + for (const { language, mapped } of commandScripts(command, source.text)) { + if (mapped.text.trim() === "") continue; + const embedded: EmbeddedScript = { + language, + text: mapped.text, + starts: mapped.starts.map((start) => source.starts[start]!), + ends: mapped.ends.map((end) => source.ends[end - 1]!), + }; + found.push(embedded); + if (language === "shellscript" && depth + 1 < MAX_DEPTH) { + found.push(...scriptsIn(embedded, depth + 1)); + } + } + }); + return found; +} + +function isNode(value: unknown): value is { readonly type: string } { + return typeof value === "object" && value !== null && "type" in value; +} + +/** Visits every simple command, including those inside `$(…)`, loops and subshells. */ +function forEachCommand( + node: unknown, + visit: (command: Command) => void, + seen = new WeakSet(), +): void { + if (Array.isArray(node)) { + for (const child of node) forEachCommand(child, visit, seen); + return; + } + if (!isNode(node) || seen.has(node)) return; + seen.add(node); + switch (node.type) { + case "Command": + visit(node as Command); + break; + case "CommandExpansion": + case "ProcessSubstitution": { + const { script } = node as Extract; + // Escaped-backtick scripts own a decoded source their positions index. + if (script && !script.errors?.length && script.source === undefined) { + forEachCommand(script.commands, visit, seen); + } + return; + } + } + // Word parts are lazy getters, which Object.values skips. + if ("parts" in node) forEachCommand(node.parts, visit, seen); + for (const value of Object.values(node)) { + if (typeof value === "object") forEachCommand(value, visit, seen); + } +} + +function programName(word: Word): string { + // Bash reads `C:\Python312\python.exe` unquoted as escapes; Windows meant a path. + const path = /\\/u.test(word.text) ? word.text.replace(/^["']|["']$/gu, "") : word.value; + return (path.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.exe$/u, ""); +} + +function interpreterFor(name: string): Interpreter | undefined { + // `py` is the Windows Python launcher, as in `py -3 -c '…'`. + if (/^python(?:\d+(?:\.\d+)?)?$|^pypy3?$|^py$/u.test(name)) return PYTHON; + return Object.hasOwn(INTERPRETERS, name) ? INTERPRETERS[name] : undefined; +} + +/** Skips `env FOO=1`, `sudo -u bob` and similar to the word naming the real program. */ +function programIndex(words: ReadonlyArray): number { + let index = 0; + while (index < words.length) { + const name = programName(words[index]!); + if (!Object.hasOwn(PROGRAM_WRAPPERS, name)) return index; + const valueOptions = PROGRAM_WRAPPERS[name]!; + index += 1; + let optionsEnded = false; + while (index < words.length) { + const value = words[index]!.value; + const isOption = !optionsEnded && value.startsWith("-"); + if (isOption && value === "--") optionsEnded = true; + // `env -- FOO=1 cmd` still takes assignments after the options end. + if (isOption || /^[A-Za-z_]\w*=/u.test(value)) { + index += isOption && valueOptions.has(value) ? 2 : 1; + } else { + break; + } + } + } + return index; +} + +function commandScripts( + command: Command, + source: string, +): Array<{ language: string; mapped: MappedText }> { + if (!command.name) return []; + const words = [command.name, ...command.args.filter((arg): arg is Word => arg.type === "Word")]; + const index = programIndex(words); + const program = words[index]; + if (!program) return []; + const name = programName(program); + const interpreter = interpreterFor(name); + const args = words.slice(index + 1); + const scripts: Array<{ language: string; mapped: MappedText }> = []; + + let readsStdinScript = false; + if (interpreter) { + const found = interpreterArgs(interpreter, args); + for (const script of found.scripts) { + const mapped = wordText(script.word, source); + if (!mapped) continue; + const start = script.inValue ? mapped.text.indexOf("=") + 1 : 0; + scripts.push({ language: interpreter.language, mapped: sliceMapped(mapped, start) }); + } + readsStdinScript = + interpreter.stdin !== "never" && found.scripts.length === 0 && !found.scriptFromFile; + } + + const stdinLanguage = readsStdinScript ? interpreter?.language : undefined; + const writtenLanguage = writtenFileLanguage(name, command, args); + for (const redirect of command.redirects) { + const toStdin = isDescriptor(redirect.descriptor, 0); + if (redirect.type === "HereString" && toStdin && stdinLanguage && redirect.target) { + const mapped = wordText(redirect.target, source); + if (mapped) scripts.push({ language: stdinLanguage, mapped }); + } + if (redirect.type === "HereDoc") { + const language = + (toStdin ? (stdinLanguage ?? writtenLanguage) : undefined) ?? delimiterLanguage(redirect); + if (language) scripts.push({ language, mapped: hereDocText(redirect, source) }); + } + } + return scripts; +} + +/** Whether a redirection without a descriptor, or with `fd`, targets file descriptor `fd`. */ +function isDescriptor(descriptor: HereDoc["descriptor"], fd: number): boolean { + return ( + descriptor === undefined || (descriptor.type === "FileDescriptor" && descriptor.value === fd) + ); +} + +interface ScriptArgument { + readonly word: Word; + /** The script follows the `=` of `--eval=…` rather than filling the word. */ + readonly inValue: boolean; +} + +/** The script arguments, and whether the script comes from a file instead. */ +function interpreterArgs( + interpreter: Interpreter, + args: ReadonlyArray, +): { scripts: ScriptArgument[]; scriptFromFile: boolean } { + const positionals: Word[] = []; + const scripts: ScriptArgument[] = []; + const whole = (word: Word): ScriptArgument => ({ word, inValue: false }); + let optionsEnded = false; + let scriptFromFile = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + const flag = interpreter.ignoreCase ? arg.value.toLowerCase() : arg.value; + if (!optionsEnded && flag === "--") { + optionsEnded = true; + continue; + } + if (!optionsEnded && flag.startsWith("-") && flag !== "-") { + // `--eval=code` and `--file=path` carry their value in the same word. + const [name = flag, value] = flag.startsWith("--") ? flag.split(/=(.*)/su) : [flag]; + if (interpreter.scriptFileOptions?.has(name)) { + if (!interpreter.repeatable) return { scripts, scriptFromFile: true }; + // SQL shells run `-f` files and `-c` statements alike, in order. + scriptFromFile = true; + if (value === undefined) index += 1; + continue; + } + if (interpreter.inline(name)) { + const next = args[index + 1]; + if (value !== undefined) scripts.push({ word: arg, inValue: true }); + else if (next) scripts.push(whole(next)); + if (!interpreter.repeatable) return { scripts, scriptFromFile: false }; + if (value === undefined) index += 1; + continue; + } + if (value === undefined && interpreter.valueOptions?.has(flag)) index += 1; + continue; + } + positionals.push(arg); + // An interpreter stops reading its own options at the script file. + if (interpreter.stdin === "without-script-file" && !interpreter.subcommand) break; + } + + if (interpreter.subcommand) { + if (positionals[0]?.value !== interpreter.subcommand) return { scripts, scriptFromFile: true }; + return { scripts: positionals.slice(1, 2).map(whole), scriptFromFile: false }; + } + if (interpreter.positionalScript !== undefined) { + scripts.push(...positionals.slice(interpreter.positionalScript).map(whole)); + } + // SQL shells take databases as positional arguments, not script files. + if (interpreter.stdin === "always") return { scripts, scriptFromFile }; + const first = positionals[0]; + return { scripts, scriptFromFile: first !== undefined && first.value !== "-" }; +} + +function sliceMapped(mapped: MappedText, start: number): MappedText { + if (start === 0) return mapped; + return { + text: mapped.text.slice(start), + starts: mapped.starts.slice(start), + ends: mapped.ends.slice(start), + }; +} + +/** The language of the file `cat > file` or `tee file` writes its stdin to. */ +function writtenFileLanguage( + name: string, + command: Command, + args: ReadonlyArray, +): string | undefined { + let path: string | undefined; + if (name === "cat") { + for (const redirect of command.redirects) { + if ( + redirect.type === "Redirect" && + [">", ">>", ">|", "&>", "&>>"].includes(redirect.operator) && + isDescriptor(redirect.descriptor, 1) + ) { + path = redirect.target?.value; + } + } + } else if (name === "tee") { + path = args.find((arg) => !arg.value.startsWith("-"))?.value; + } + if (!path) return undefined; + const language = getFiletypeFromFileName(path); + return language === "text" ? undefined : language; +} + +function delimiterLanguage(hereDoc: HereDoc): string | undefined { + const name = hereDoc.delimiter?.value.toLowerCase(); + return name && Object.hasOwn(DELIMITER_LANGUAGES, name) ? DELIMITER_LANGUAGES[name] : undefined; +} + +/** A heredoc body as written; the interpreter reads its characters one to one. */ +function hereDocText(hereDoc: HereDoc, source: string): MappedText { + const { pos, end } = hereDoc.body; + const starts = Array.from({ length: end - pos }, (_, index) => pos + index); + return { + text: source.slice(pos, end), + starts, + ends: starts.map((start) => start + 1), + }; +} + +/** + * A word's value with the source span of each character: quotes removed and + * escapes resolved, the way the program receives it. Expansions such as + * `$HOME` stay as written. Returns undefined for spellings it cannot map. + */ +function wordText(word: Word, source: string): MappedText | undefined { + const builder = new MappedTextBuilder(); + const parts: ReadonlyArray = word.parts ?? [ + { type: "Literal", pos: word.pos, end: word.end, text: word.text, value: word.value }, + ]; + for (const part of parts) { + switch (part.type) { + case "Literal": + if (!builder.addEscaped(part, false)) return undefined; + break; + case "SingleQuoted": + builder.addRaw(part.value, part.pos + 1); + break; + case "DoubleQuoted": + case "LocaleString": + for (const child of part.parts) { + if (!builder.addDoubleQuotedChild(child)) return undefined; + } + break; + case "AnsiCQuoted": + // $'…' escapes such as \x41 do not map character for character. + if (part.value !== part.text.slice(2, -1)) return undefined; + builder.addRaw(part.value, part.pos + 2); + break; + default: + builder.addRaw(source.slice(part.pos, part.end), part.pos); + } + } + return builder.build(); +} + +class MappedTextBuilder { + private text = ""; + private readonly starts: number[] = []; + private readonly ends: number[] = []; + + addRaw(text: string, pos: number) { + for (let index = 0; index < text.length; index += 1) this.push(text[index]!, pos + index, 1); + } + + addDoubleQuotedChild(child: DoubleQuotedChild): boolean { + if (child.type === "Literal") return this.addEscaped(child, true); + this.addRaw(child.text, child.pos); + return true; + } + + /** Adds a literal, resolving backslash escapes; false when the result disagrees with the parser. */ + addEscaped(literal: { pos: number; text: string; value: string }, doubleQuoted: boolean) { + const before = this.text.length; + const { text, pos } = literal; + for (let index = 0; index < text.length; index += 1) { + const next = text[index + 1]; + if ( + text[index] === "\\" && + next !== undefined && + (!doubleQuoted || '$`"\\\n'.includes(next)) + ) { + // A backslash-newline joins lines and spells nothing. + if (next !== "\n") this.push(next, pos + index, 2); + index += 1; + } else { + this.push(text[index]!, pos + index, 1); + } + } + return this.text.slice(before) === literal.value; + } + + build(): MappedText { + return { text: this.text, starts: this.starts, ends: this.ends }; + } + + private push(character: string, start: number, length: number) { + this.text += character; + this.starts.push(start); + this.ends.push(start + length); + } +} diff --git a/packages/client-runtime/src/work-log/commandLabel.test.ts b/packages/client-runtime/src/work-log/commandLabel.test.ts index e048ef35d7c1..2ba38fd37a57 100644 --- a/packages/client-runtime/src/work-log/commandLabel.test.ts +++ b/packages/client-runtime/src/work-log/commandLabel.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { commandDisplayText, commandProgramName } from "./commandLabel.ts"; +import { + commandDisplayText, + commandHighlightLanguage, + commandProgramName, +} from "./commandLabel.ts"; describe("commandProgramName", () => { it.each([ @@ -493,3 +497,107 @@ describe("commandDisplayText", () => { expect(commandDisplayText(input)).toBe(expected); }); }); + +describe("commands recorded from each provider", () => { + // Inputs are copied from the recorded replay transcripts in + // apps/server/src/orchestration-v2/testkit/fixtures (turn_interrupt_mid_tool, + // todo_list, grok_background_bash and + // opencode_running_child_approval). In turn_interrupt_mid_tool every provider + // ran the same script, and only Codex wraps it in a login shell. + const interruptScript = `node -e "console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)"`; + it.each([ + ["Claude", interruptScript, interruptScript], + ["Cursor", interruptScript, interruptScript], + ["Pi", interruptScript, interruptScript], + [ + "Codex", + `/bin/bash -lc "node -e \\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\""`, + interruptScript, + ], + [ + "Codex", + "/bin/bash -lc 'cat package.json && cat tsconfig.json'", + "cat package.json && cat tsconfig.json", + ], + [ + "Grok", + "for i in 1 2 3; do sleep 8; echo tock $i; done", + "for i in 1 2 3; do sleep 8; echo tock $i; done", + ], + [ + "OpenCode", + "printf 'running child approval' > running-child.txt", + "printf 'running child approval' > running-child.txt", + ], + ])("%s shows the script it ran: %s", (_provider, input, script) => { + expect(commandDisplayText(input)).toBe(script); + expect(commandHighlightLanguage(commandDisplayText(input))).toBe("shellscript"); + }); +}); + +describe("commandDisplayText with redacted Codex commands", () => { + // Codex replaces secrets in command text before sending it + // (codex-rs/secrets/src/sanitizer.rs). Usually the quoting survives. + it("unwraps a redacted command whose quoting is intact", () => { + expect( + commandDisplayText( + `/bin/zsh -lc 'curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com'`, + ), + ).toBe('curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com'); + }); + + // Its secret-assignment pattern can also swallow the backslash of an escaped + // quote: `token=config[\"apiToken\"]` becomes `token=[REDACTED_SECRET]"apiToken\"]`. + // The bare quote ends the -lc argument early, so where the script ends is unknown. + it("keeps the wrapper when redaction broke the quoting", () => { + const input = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`; + expect(commandDisplayText(input)).toBe(input); + }); +}); + +describe("commandDisplayText control characters", () => { + it.each([ + ["printf '\u001b[31mred\u001b[0m'", "printf '␛[31mred␛[0m'"], + ["zsh -lc 'printf \"\u001b[1m\"'", 'printf "␛[1m"'], + ["echo bell\u0007 delete\u007f", "echo bell␇ delete␡"], + ["printf 'a\\tb'\r\necho done", "printf 'a\\tb'\r\necho done"], + ["cat <<'EOF'\n\tindented\nEOF", "cat <<'EOF'\n\tindented\nEOF"], + ])("shows %j as %j", (input, expected) => { + expect(commandDisplayText(input)).toBe(expected); + }); +}); + +describe("commandHighlightLanguage", () => { + it.each([ + [ + '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -Command "Get-ChildItem -Recurse"', + "powershell", + ], + [ + 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\\work\\scripts\\doctor.ps1"', + "powershell", + ], + ["& pwsh -c 'Get-Date'", "powershell"], + ["PWSH.EXE -Command Get-Date", "powershell"], + ["pwsh-preview -c 'Get-Date'", "shellscript"], + ["git status; pwsh -c 'Get-Date'", "shellscript"], + ["cat <<'EOF' > notes.txt\npwsh\nEOF", "shellscript"], + ["", "shellscript"], + // Windows agents also run PowerShell directly, without a pwsh wrapper. + ["Get-Content package.json | Select-String version", "powershell"], + ["$env:CI='1'; npm test", "powershell"], + ["$tmp = Join-Path $env:TEMP repo; git clone example", "powershell"], + ['"=== CHECK FILE ==="; Get-Content file.txt', "powershell"], + ["& 'C:\\Python312\\python.exe' -c 'print(1)'", "powershell"], + ["Install-Module Pester -Scope CurrentUser", "powershell"], + ["ConvertTo-Json @{ a = 1 }", "powershell"], + ["update-alternatives --list java", "shellscript"], + ["install-info --version", "shellscript"], + ["Make-Thing now", "shellscript"], + ["echo Get-Content; git status", "shellscript"], + ["FOO=1 npm test", "shellscript"], + ["echo $HOME && ls", "shellscript"], + ])("%s", (command, language) => { + expect(commandHighlightLanguage(command)).toBe(language); + }); +}); diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts index c3df952f477d..12557bdc8698 100644 --- a/packages/client-runtime/src/work-log/commandLabel.ts +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -1368,8 +1368,31 @@ export function commandProgramName(command: string, depth = 0): string | null { return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS); } -/** Removes a plain shell -c wrapper for display; callers retain the original for details. */ +// Escape bytes and other control characters render as invisible gaps; display +// shows them as Unicode control pictures (ESC becomes ␛) instead. Tab, newline +// and carriage return lay out as whitespace, so CRLF scripts stay unmarked. +// oxlint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu; + +/** + * The command as rows show it: a plain shell -c wrapper removed and control + * characters made visible. Callers retain the original for details. + */ export function commandDisplayText(command: string): string { + return withVisibleControlCharacters(withoutShellWrapper(command)); +} + +/** Replaces each control character with its control picture; the length does not change. */ +export function withVisibleControlCharacters(text: string): string { + return text.replace(CONTROL_CHARACTERS, (character) => + character === "\u007f" ? "␡" : String.fromCharCode(0x2400 + character.charCodeAt(0)), + ); +} + +// A wrapper whose quoting does not balance is kept: the script's end cannot be +// known, which happens when Codex's secret redaction removes the backslash of +// an escaped quote inside `-lc "…"`. +function withoutShellWrapper(command: string): string { const trimmed = command.trim(); const split = splitFirstShellCommand(trimmed); if (split.remainingCommand !== null) return trimmed; @@ -1384,3 +1407,55 @@ export function commandDisplayText(command: string): string { if (scriptIndex === null || scriptIndex !== tokens.length - 1) return trimmed; return tokens[scriptIndex]?.trim() || trimmed; } + +const POWERSHELL_PROGRAMS = new Set(["powershell", "pwsh"]); + +// PowerShell's approved verbs (`Get-Verb`), plus the ForEach, Where, Sort and +// Tee of its built-in *-Object cmdlets. Cmdlets match only capitalized, as +// agents write them: lowercase `update-grub` or `install-info` are Linux tools. +const POWERSHELL_VERBS = new Set( + `Add Approve Assert Backup Block Build Checkpoint Clear Close Compare Complete Compress Confirm Connect Convert ConvertFrom ConvertTo Copy Debug Deny Deploy Disable Disconnect Dismount Edit Enable Enter Exit Expand Export Find ForEach Format Get Grant Group Hide Import Initialize Install Invoke Join Limit Lock Measure Merge Mount Move New Open Optimize Out Ping Pop Protect Publish Push Read Receive Redo Register Remove Rename Repair Request Reset Resize Resolve Restart Restore Resume Revoke Save Search Select Send Set Show Skip Sort Split Start Step Stop Submit Suspend Switch Sync Tee Test Trace Unblock Undo Uninstall Unlock Unprotect Unpublish Unregister Update Use Wait Watch Where Write`.split( + " ", + ), +); + +function isPowerShellCmdlet(word: string): boolean { + const cmdlet = /^([A-Z][a-z]+(?:[A-Z][a-z]+)?)-[A-Z][A-Za-z]*$/u.exec(word); + return cmdlet !== null && POWERSHELL_VERBS.has(cmdlet[1]!); +} + +/** + * The grammar to highlight a command with: PowerShell when pwsh or powershell + * runs it, or when it is written in PowerShell, as Windows agents run it. + */ +export function commandHighlightLanguage(command: string): "powershell" | "shellscript" { + const program = /^(?:&\s*)?(?:"([^"]*)"|'([^']*)'|(\S+))/u.exec(command.trim()); + const name = (program?.[1] ?? program?.[2] ?? program?.[3] ?? "") + .split(/[\\/]/u) + .at(-1) + ?.toLowerCase() + .replace(/\.exe$/u, ""); + if (name && POWERSHELL_PROGRAMS.has(name)) return "powershell"; + return isPowerShellScript(command) ? "powershell" : "shellscript"; +} + +/** Whether any statement starts like PowerShell and never like POSIX shell. */ +function isPowerShellScript(command: string): boolean { + let rest: string | null = command.trim(); + // A leading call operator; POSIX shell cannot start a command with `&`. + if (/^&\s*\S/u.test(rest)) return true; + for (let segment = 0; rest && segment < MAX_COMMAND_SEGMENTS; segment += 1) { + const { firstCommand, remainingCommand } = splitFirstShellCommand(rest); + const statement = firstCommand.trim(); + if ( + // `$env:NAME` or `$name = value`; neither parses as POSIX shell. + /^\$(?:env|global|local|script):/iu.test(statement) || + /^\$[A-Za-z_]\w*\s+=/u.test(statement) || + isPowerShellCmdlet(statement.match(/^\S+/u)?.[0] ?? "") + ) { + return true; + } + rest = remainingCommand; + } + return false; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02883a8b6536..447b1c9e77f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -755,6 +755,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + unbash: + specifier: ^5.0.0 + version: 5.0.0 zustand: specifier: ^5.0.11 version: 5.0.14(@types/react@19.3.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -10825,6 +10828,10 @@ packages: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} engines: {node: '>=14'} + unbash@5.0.0: + resolution: {integrity: sha512-RYNm/ISFtflWpIcXTPHHLJZ0mqzInav+L9ke9PcL+hHsSf9+Fd/syPb614Mlqs/O3AYgjnvxDOFl0a+zVqi1/w==} + engines: {node: '>=14'} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -21998,6 +22005,8 @@ snapshots: unbash@4.0.11: {} + unbash@5.0.0: {} + uncrypto@0.1.3: {} undici-types@7.16.0: {} From 250e052f44dd313b658abebc707242a7b25be340 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad <48803618+Brentlok@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:57:04 +0200 Subject: [PATCH 04/21] fix(mobile): upgrade Uniwind and remove local patch (#14597) --- apps/mobile/package.json | 2 +- .../src/lib/uniwind-platform-variants.test.ts | 10 +- docs/internals/mobile-development.md | 11 +- patches/uniwind@1.11.0.patch | 1011 ----------------- pnpm-lock.yaml | 11 +- pnpm-workspace.yaml | 2 +- 6 files changed, 16 insertions(+), 1031 deletions(-) delete mode 100644 patches/uniwind@1.11.0.patch diff --git a/apps/mobile/package.json b/apps/mobile/package.json index df0d8aea4c52..602ffb66bace 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -127,7 +127,7 @@ "react-native-worklets": "0.13.0", "shiki": "4.2.0", "tailwind-merge": "^3.5.0", - "uniwind": "1.11.0" + "uniwind": "1.12.1" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/mobile/src/lib/uniwind-platform-variants.test.ts b/apps/mobile/src/lib/uniwind-platform-variants.test.ts index 12fa86b33c08..32e0323e7a19 100644 --- a/apps/mobile/src/lib/uniwind-platform-variants.test.ts +++ b/apps/mobile/src/lib/uniwind-platform-variants.test.ts @@ -10,7 +10,7 @@ import { beforeAll, describe, expect, it } from "vite-plus/test"; // `@media ios { ... }` block, and uniwind's CSS processor used to drop the // block's media queries after its first nested rule. Everything past the first // utility compiled unguarded and shipped to both platforms. These tests run -// the installed (patched) uniwind compiler over real Tailwind output, per +// the installed uniwind compiler over real Tailwind output, per // platform, and assert what each bundle receives. The compiler itself runs in // a plain Node child process (uniwind-platform-variants.fixture.cjs) so no // test-runner module transforms sit between the test and the shipped code. @@ -215,11 +215,9 @@ describe("uniwind keeps media rules nested inside class rules attached to the cl }); }); -// The patch file carries three independent uniwind fixes (state/data selector -// variants, the Metro native-styles fingerprint, and this media-query scoping -// fix). Regenerating it for one fix must not silently drop the others — this -// suite exercises the shipped transformer and compiler for the other two. -describe("uniwind patch keeps pre-existing selector and transformer behavior", () => { +// Keep guarding selector variants and the Metro native-styles fingerprint +// alongside media-query scoping when upgrading the installed Uniwind package. +describe("uniwind keeps selector and transformer behavior", () => { let output: FixtureOutput; beforeAll(() => { diff --git a/docs/internals/mobile-development.md b/docs/internals/mobile-development.md index 9f0cb9d2e2fb..d1d375584bb0 100644 --- a/docs/internals/mobile-development.md +++ b/docs/internals/mobile-development.md @@ -17,12 +17,11 @@ shutdown, but a supervisor created after its cleanup runs would escape it. A clo parent scope also closes late arrivals, preventing interrupted startup or runtime replacement from leaving a WebSocket alive outside the new registry. -The [Uniwind patch](../../patches/uniwind@1.11.0.patch) still compiles CSS on Metro -updates so newly used classes are discovered. It skips global style invalidation -only when the generated stylesheet and theme list are unchanged. Skipping compilation -would lose new classes; invalidating every consumer for unchanged output makes an -ordinary component edit refresh the whole app. The fingerprint is recorded only -after initialization succeeds. +Uniwind compiles CSS on Metro updates so newly used classes are discovered. It +skips global style invalidation only when the generated stylesheet and theme list +are unchanged. Skipping compilation would lose new classes; invalidating every +consumer for unchanged output makes an ordinary component edit refresh the whole +app. The fingerprint is recorded only after initialization succeeds. The [expo-notifications patch](../../patches/expo-notifications@57.0.15.patch) protects `NotificationCenterManager`'s delegates and pending responses with a lock. React runtimes can diff --git a/patches/uniwind@1.11.0.patch b/patches/uniwind@1.11.0.patch deleted file mode 100644 index 4a418bf6b077..000000000000 --- a/patches/uniwind@1.11.0.patch +++ /dev/null @@ -1,1011 +0,0 @@ -diff --git a/dist/common/bundler/css-processor/processor.js b/dist/common/bundler/css-processor/processor.js -index 7dd369f..48321d1 100644 ---- a/dist/common/bundler/css-processor/processor.js -+++ b/dist/common/bundler/css-processor/processor.js -@@ -105,6 +105,64 @@ class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(_utils.isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -120,64 +178,30 @@ class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach(selector2 => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach(selector3 => { -- selector3.forEach(selector4 => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(_utils.isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach(rule2 => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach(selectorToken => { -@@ -199,11 +223,16 @@ class ProcessorBuilder { - const { - mediaQueries - } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach(rule2 => { -+ this.declarationConfig = { -+ ...outerConfig, -+ mediaQueries: blockMediaQueries -+ }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs -index d57ca81..83b58d1 100644 ---- a/dist/metro/transformer.cjs -+++ b/dist/metro/transformer.cjs -@@ -7,6 +7,7 @@ const culori = require('culori'); - const node = require('@tailwindcss/node'); - const oxide = require('@tailwindcss/oxide'); - const fs = require('fs'); -+const node_crypto = require('node:crypto'); - - function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } - -@@ -1537,6 +1538,64 @@ class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(config.isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -1552,64 +1611,30 @@ class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach((selector2) => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach((selector3) => { -- selector3.forEach((selector4) => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(config.isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach((selectorToken) => { -@@ -1629,11 +1654,13 @@ class ProcessorBuilder { - } - if (rule.type === "media") { - const { mediaQueries } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach((rule2) => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -@@ -1753,10 +1780,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => { - await bundlerConfig.generateArtifacts(cssArtifactPath); - const virtualCode = await compileCSS(bundlerConfig); - const isWeb = bundlerConfig.platform === config.Platform.Web; -+ const nativeStylesFingerprint = isWeb ? void 0 : node_crypto.createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex"); - data = Buffer.from( - isWeb ? virtualCode : [ - `const { Uniwind } = require('uniwind');`, -- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});` -+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');` - ].join(""), - "utf-8" - ); -diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs -index d3e7475..f92b73b 100644 ---- a/dist/metro/transformer.mjs -+++ b/dist/metro/transformer.mjs -@@ -5,6 +5,7 @@ import { converter, parse, formatHex, formatHex8 } from 'culori'; - import { compile } from '@tailwindcss/node'; - import { Scanner } from '@tailwindcss/oxide'; - import fs from 'fs'; -+import { createHash } from 'node:crypto'; - - const toCamelCase = (str) => str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); - const pipe = (data) => ((...fns) => fns.reduce((acc, fn) => fn(acc), data)); -@@ -1530,6 +1531,64 @@ class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -1545,64 +1604,30 @@ class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach((selector2) => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach((selector3) => { -- selector3.forEach((selector4) => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach((selectorToken) => { -@@ -1622,11 +1647,13 @@ class ProcessorBuilder { - } - if (rule.type === "media") { - const { mediaQueries } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach((rule2) => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -@@ -1746,10 +1773,11 @@ const transform = async (config, projectRoot, filePath, data, options) => { - await bundlerConfig.generateArtifacts(cssArtifactPath); - const virtualCode = await compileCSS(bundlerConfig); - const isWeb = bundlerConfig.platform === Platform.Web; -+ const nativeStylesFingerprint = isWeb ? void 0 : createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex"); - data = Buffer.from( - isWeb ? virtualCode : [ - `const { Uniwind } = require('uniwind');`, -- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});` -+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');` - ].join(""), - "utf-8" - ); -diff --git a/dist/module/bundler/css-processor/processor.js b/dist/module/bundler/css-processor/processor.js -index 58c96b6..6849ca7 100644 ---- a/dist/module/bundler/css-processor/processor.js -+++ b/dist/module/bundler/css-processor/processor.js -@@ -99,6 +99,64 @@ export class ProcessorBuilder { - style.importantProperties.push(declaration.property); - } - } -+ readSelectorVariants(tokens) { -+ const variants = { -+ rtl: null, -+ theme: null, -+ active: null, -+ focus: null, -+ disabled: null, -+ dataAttributes: null -+ }; -+ tokens.forEach((token) => { -+ if (token.type === "pseudo-class" && token.kind === "where") { -+ token.selectors.forEach((compound) => { -+ compound.forEach((inner) => { -+ if (inner.type === "class" && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name; -+ } -+ if (inner.type === "pseudo-class" && inner.kind === "dir") { -+ variants.rtl = inner.direction === "rtl"; -+ } -+ }); -+ }); -+ } -+ if (token.type === "pseudo-class" && token.kind === "active") { -+ variants.active = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "focus") { -+ variants.focus = true; -+ } -+ if (token.type === "pseudo-class" && token.kind === "disabled") { -+ variants.disabled = true; -+ } -+ if (token.type === "attribute" && token.operation === null && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"true"`; -+ } -+ if (token.type === "attribute" && token.operation?.operator === "equal" && token.name.startsWith("data-")) { -+ variants.dataAttributes ??= {}; -+ variants.dataAttributes[token.name] = `"${token.operation.value}"`; -+ } -+ }); -+ const hasVariants = Object.values(variants).some(isDefined); -+ return { variants, hasVariants }; -+ } -+ withSelectorVariants(variants, run) { -+ this.declarationConfig.rtl ??= variants.rtl; -+ this.declarationConfig.theme ??= variants.theme; -+ this.declarationConfig.active ??= variants.active; -+ this.declarationConfig.focus ??= variants.focus; -+ this.declarationConfig.disabled ??= variants.disabled; -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes; -+ run(); -+ this.declarationConfig.rtl = null; -+ this.declarationConfig.theme = null; -+ this.declarationConfig.active = null; -+ this.declarationConfig.focus = null; -+ this.declarationConfig.disabled = null; -+ this.declarationConfig.dataAttributes = null; -+ } - parseRuleRec(rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1); -@@ -114,64 +172,30 @@ export class ProcessorBuilder { - this.declarationConfig.className = newClassName; - this.stylesheets[newClassName] ??= []; - this.stylesheets[newClassName].push({}); -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- return; -- } -- let rtl = null; -- let theme = null; -- let active = null; -- let focus = null; -- let disabled = null; -- let dataAttributes = null; -- selector.forEach((selector2) => { -- if (selector2.type === "pseudo-class" && selector2.kind === "where") { -- selector2.selectors.forEach((selector3) => { -- selector3.forEach((selector4) => { -- if (selector4.type === "class" && this.bundlerConfig.themes.includes(selector4.name)) { -- theme = selector4.name; -- } -- if (selector4.type === "pseudo-class" && selector4.kind === "dir") { -- rtl = selector4.direction === "rtl"; -- } -- }); -- }); -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "active") { -- active = true; -+ const trailing = selector.slice(1); -+ const { variants, hasVariants } = this.readSelectorVariants(trailing); -+ if (trailing.length > 0 && !hasVariants) { -+ return; - } -- if (selector2.type === "pseudo-class" && selector2.kind === "focus") { -- focus = true; -- } -- if (selector2.type === "pseudo-class" && selector2.kind === "disabled") { -- disabled = true; -- } -- if (selector2.type === "attribute" && selector2.operation === null && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"true"`; -- } -- if (selector2.type === "attribute" && selector2.operation?.operator === "equal" && selector2.name.startsWith("data-")) { -- dataAttributes ??= {}; -- dataAttributes[selector2.name] = `"${selector2.operation.value}"`; -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }; -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule); -+ } else { -+ parseClassRule(); - } -- }); -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) { -- this.declarationConfig.rtl ??= rtl; -- this.declarationConfig.theme ??= theme; -- this.declarationConfig.active ??= active; -- this.declarationConfig.focus ??= focus; -- this.declarationConfig.disabled ??= disabled; -- this.declarationConfig.dataAttributes ??= dataAttributes; -- rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -- rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -- rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -- this.declarationConfig.rtl = null; -- this.declarationConfig.theme = null; -- this.declarationConfig.active = null; -- this.declarationConfig.focus = null; -- this.declarationConfig.disabled = null; -- this.declarationConfig.dataAttributes = null; -+ return; -+ } -+ const { variants, hasVariants } = this.readSelectorVariants(selector); -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach((declaration) => this.addDeclaration(declaration)); -+ rule.value.declarations?.importantDeclarations?.forEach((declaration) => this.addDeclaration(declaration, true)); -+ rule.value.rules?.forEach((rule2) => this.parseRuleRec(rule2)); -+ }); - return; - } - selector.forEach((selectorToken) => { -@@ -191,11 +215,13 @@ export class ProcessorBuilder { - } - if (rule.type === "media") { - const { mediaQueries } = rule.value.query; -- this.declarationConfig.mediaQueries.push(...mediaQueries); -+ const outerConfig = this.declarationConfig; -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; - rule.value.rules.forEach((rule2) => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; - this.parseRuleRec(rule2); -- this.declarationConfig = this.getDeclarationConfig(); - }); -+ this.declarationConfig = outerConfig; - return; - } - if (rule.type === "layer-block") { -diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts -index 08059b0..05c0515 100644 ---- a/src/bundler/adapters/metro/transformer.ts -+++ b/src/bundler/adapters/metro/transformer.ts -@@ -5,6 +5,7 @@ import { Platform } from '@/common/consts' - import type * as ExpoMetroConfig from '@expo/metro-config' - import type * as MetroTransformWorker from 'metro-transform-worker' - import type { JsTransformerConfig, JsTransformOptions } from 'metro-transform-worker' -+import { createHash } from 'node:crypto' - import path from 'path' - - const cssArtifactPath = path.resolve(__dirname, '../../uniwind.css') -@@ -69,13 +70,20 @@ export const transform = async ( - await bundlerConfig.generateArtifacts(cssArtifactPath) - const virtualCode = await compileCSS(bundlerConfig) - const isWeb = bundlerConfig.platform === Platform.Web -+ const nativeStylesFingerprint = isWeb -+ ? undefined -+ : createHash('sha256') -+ .update(virtualCode) -+ .update('\0') -+ .update(bundlerConfig.stringifiedThemes) -+ .digest('hex') - - data = Buffer.from( - isWeb - ? virtualCode - : [ - `const { Uniwind } = require('uniwind');`, -- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`, -+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');`, - ].join(''), - 'utf-8', - ) -diff --git a/src/bundler/css-processor/processor.ts b/src/bundler/css-processor/processor.ts -index c8b3bb8..4af4199 100644 ---- a/src/bundler/css-processor/processor.ts -+++ b/src/bundler/css-processor/processor.ts -@@ -123,6 +123,77 @@ export class ProcessorBuilder { - } - } - -+ private readSelectorVariants(tokens: ReadonlyArray) { -+ const variants = { -+ rtl: null as boolean | null, -+ theme: null as string | null, -+ active: null as boolean | null, -+ focus: null as boolean | null, -+ disabled: null as boolean | null, -+ dataAttributes: null as Record | null, -+ } -+ -+ tokens.forEach(token => { -+ if (token.type === 'pseudo-class' && token.kind === 'where') { -+ token.selectors.forEach((compound: Array) => { -+ compound.forEach(inner => { -+ if (inner.type === 'class' && this.bundlerConfig.themes.includes(inner.name)) { -+ variants.theme = inner.name -+ } -+ -+ if (inner.type === 'pseudo-class' && inner.kind === 'dir') { -+ variants.rtl = inner.direction === 'rtl' -+ } -+ }) -+ }) -+ } -+ -+ if (token.type === 'pseudo-class' && token.kind === 'active') { -+ variants.active = true -+ } -+ -+ if (token.type === 'pseudo-class' && token.kind === 'focus') { -+ variants.focus = true -+ } -+ -+ if (token.type === 'pseudo-class' && token.kind === 'disabled') { -+ variants.disabled = true -+ } -+ -+ // data-x -+ if (token.type === 'attribute' && token.operation === null && token.name.startsWith('data-')) { -+ variants.dataAttributes ??= {} -+ variants.dataAttributes[token.name] = `"true"` -+ } -+ -+ // data-x= -+ if (token.type === 'attribute' && token.operation?.operator === 'equal' && token.name.startsWith('data-')) { -+ variants.dataAttributes ??= {} -+ variants.dataAttributes[token.name] = `"${token.operation.value}"` -+ } -+ }) -+ -+ return { variants, hasVariants: Object.values(variants).some(isDefined) } -+ } -+ -+ private withSelectorVariants(variants: ReturnType['variants'], run: () => void) { -+ this.declarationConfig.rtl ??= variants.rtl -+ this.declarationConfig.theme ??= variants.theme -+ this.declarationConfig.active ??= variants.active -+ this.declarationConfig.focus ??= variants.focus -+ this.declarationConfig.disabled ??= variants.disabled -+ this.declarationConfig.dataAttributes ??= variants.dataAttributes -+ -+ run() -+ -+ this.declarationConfig.rtl = null -+ this.declarationConfig.theme = null -+ this.declarationConfig.active = null -+ this.declarationConfig.focus = null -+ this.declarationConfig.disabled = null -+ this.declarationConfig.dataAttributes = null -+ } -+ - private parseRuleRec(rule: Rule) { - if (this.declarationConfig.className !== null) { - const lastStyle = this.stylesheets[this.declarationConfig.className]?.at(-1) -@@ -142,78 +213,41 @@ export class ProcessorBuilder { - this.stylesheets[newClassName] ??= [] - this.stylesheets[newClassName].push({}) - -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -- rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) -+ // Tailwind >= 4.3.3 emits `.active\:x:active {}` instead of -+ // nesting `&:active` under the class, so the variant tokens -+ // sit right after the class token. A compound the runtime -+ // cannot express (e.g. `[aria-disabled="true"]`) used to be -+ // an empty nested rule and must not become unconditional. -+ const trailing = selector.slice(1) -+ const { variants, hasVariants } = this.readSelectorVariants(trailing) - -- return -- } -- -- let rtl = null as boolean | null -- let theme = null as string | null -- let active = null as boolean | null -- let focus = null as boolean | null -- let disabled = null as boolean | null -- let dataAttributes = null as Record | null -- -- selector.forEach(selector => { -- if (selector.type === 'pseudo-class' && selector.kind === 'where') { -- selector.selectors.forEach(selector => { -- selector.forEach(selector => { -- if (selector.type === 'class' && this.bundlerConfig.themes.includes(selector.name)) { -- theme = selector.name -- } -- -- if (selector.type === 'pseudo-class' && selector.kind === 'dir') { -- rtl = selector.direction === 'rtl' -- } -- }) -- }) -+ if (trailing.length > 0 && !hasVariants) { -+ return - } - -- if (selector.type === 'pseudo-class' && selector.kind === 'active') { -- active = true -- } -- -- if (selector.type === 'pseudo-class' && selector.kind === 'focus') { -- focus = true -+ const parseClassRule = () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -+ rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) - } - -- if (selector.type === 'pseudo-class' && selector.kind === 'disabled') { -- disabled = true -+ if (hasVariants) { -+ this.withSelectorVariants(variants, parseClassRule) -+ } else { -+ parseClassRule() - } - -- // data-x -- if (selector.type === 'attribute' && selector.operation === null && selector.name.startsWith('data-')) { -- dataAttributes ??= {} -- dataAttributes[selector.name] = `"true"` -- } -+ return -+ } - -- // data-x= -- if (selector.type === 'attribute' && selector.operation?.operator === 'equal' && selector.name.startsWith('data-')) { -- dataAttributes ??= {} -- dataAttributes[selector.name] = `"${selector.operation.value}"` -- } -- }) -+ const { variants, hasVariants } = this.readSelectorVariants(selector) - -- if ([rtl, theme, active, focus, disabled, dataAttributes].some(isDefined)) { -- this.declarationConfig.rtl ??= rtl -- this.declarationConfig.theme ??= theme -- this.declarationConfig.active ??= active -- this.declarationConfig.focus ??= focus -- this.declarationConfig.disabled ??= disabled -- this.declarationConfig.dataAttributes ??= dataAttributes -- -- rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -- rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -- rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) -- -- this.declarationConfig.rtl = null -- this.declarationConfig.theme = null -- this.declarationConfig.active = null -- this.declarationConfig.focus = null -- this.declarationConfig.disabled = null -- this.declarationConfig.dataAttributes = null -+ if (hasVariants) { -+ this.withSelectorVariants(variants, () => { -+ rule.value.declarations?.declarations?.forEach(declaration => this.addDeclaration(declaration)) -+ rule.value.declarations?.importantDeclarations?.forEach(declaration => this.addDeclaration(declaration, true)) -+ rule.value.rules?.forEach(rule => this.parseRuleRec(rule)) -+ }) - - return - } -@@ -241,13 +275,21 @@ export class ProcessorBuilder { - - if (rule.type === 'media') { - const { mediaQueries } = rule.value.query -+ // Media queries have to outlive the reset between the block's sibling -+ // rules: resetting only after each parse left every rule past the first -+ // without the block's queries, shipping ios:/android: variants and later -+ // utilities of a width breakpoint unguarded to every platform. Reset to -+ // the outer config instead of a fresh one so media nested inside a class -+ // rule keeps writing into that class. -+ const outerConfig = this.declarationConfig -+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries] - -- this.declarationConfig.mediaQueries.push(...mediaQueries) - rule.value.rules.forEach(rule => { -+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries } - this.parseRuleRec(rule) -- this.declarationConfig = this.getDeclarationConfig() - }) - -+ this.declarationConfig = outerConfig - return - } - -diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts -index 5e0832b..01aed1b 100644 ---- a/src/core/config/config.native.ts -+++ b/src/core/config/config.native.ts -@@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty - import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common' - - class UniwindConfigBuilder extends UniwindConfigBuilderBase { -+ private stylesFingerprint: string | undefined -+ - constructor() { - super() - } -@@ -35,9 +37,18 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { - UniwindListener.notify([StyleDependency.Insets]) - } - -- protected __reinit(generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) { -+ protected __reinit( -+ generateStyleSheetCallback: GenerateStyleSheetsCallback, -+ themes: Array, -+ stylesFingerprint?: string, -+ ) { -+ if (__DEV__ && stylesFingerprint !== undefined && stylesFingerprint === this.stylesFingerprint) { -+ return -+ } -+ - super.__reinit(generateStyleSheetCallback, themes) - UniwindStore.reinit(generateStyleSheetCallback, themes) -+ this.stylesFingerprint = stylesFingerprint - } - - protected onThemeChange() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 447b1c9e77f9..2c4421d93ead 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,6 @@ patchedDependencies: react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483 - uniwind@1.11.0: a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625 importers: @@ -487,8 +486,8 @@ importers: specifier: ^3.5.0 version: 3.6.0 uniwind: - specifier: 1.11.0 - version: 1.11.0(patch_hash=a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625)(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3) + specifier: 1.12.1 + version: 1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3) devDependencies: '@effect/vitest': specifier: 4.0.0-rc.115 @@ -10905,8 +10904,8 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - uniwind@1.11.0: - resolution: {integrity: sha512-e2mYlHrZzAQtZUf3STrfVDTetspv7p5vm/cu6p8kjLRQaL9r6QFG+MlwZ3hrI2LHnzgzjbSo+MwvoE380zz2JQ==} + uniwind@1.12.1: + resolution: {integrity: sha512-u0OVMVqefGchOpsPIwIYvcF2Jl9JtVVaXBb+wfqAYoyLrhN27Ll50aQ7aFT81k73JSq+He6dNlcbTl2Qb9cTMw==} hasBin: true peerDependencies: '@expo/metro-config': 58.0.6 @@ -22081,7 +22080,7 @@ snapshots: universalify@2.0.1: {} - uniwind@1.11.0(patch_hash=a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625)(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3): + uniwind@1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3): dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5bd65fcacbbb..11f052624a48 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -125,6 +125,7 @@ minimumReleaseAgeExclude: - "@distilled.cloud/stripe@1.0.0-rc.12" - "@shadcn/lint@0.1.5" - cn@0.3.2 + - uniwind@1.12.1 - "@expo/dom-webview@58.0.2" - "expo-auth-session@58.0.7" - "expo-camera@58.0.7" @@ -263,7 +264,6 @@ patchedDependencies: react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch - uniwind@1.11.0: patches/uniwind@1.11.0.patch peerDependencyRules: allowAny: From cf3e714b0f58e29c8fa8660db50d2187e3263b65 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 5 Oct 2026 00:13:16 -0700 Subject: [PATCH 05/21] fix(server): Stop ends a Codex command after its thread was settled (#15546) Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/CodexAdapterV2.test.ts | 14 +++++++++++--- .../orchestration-v2/Adapters/CodexAdapterV2.ts | 7 +++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 21b398cdc7ad..f8b635a9fa9c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -3917,7 +3917,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const backgroundStopCases = [true, false, "still_running"] as const; + // "thread_unloaded": the thread was settled, so T3 unsubscribed and Codex + // unloaded it (killing its terminals) before Stop arrived. + const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { const stillRunning = terminated === "still_running"; return makeCodexReplayTranscript({ @@ -3936,9 +3938,15 @@ describe("CodexAdapterV2 post-settle continuation", () => { { type: "emit_inbound", label: "terminate-background-command", - frame: { id: 4, result: { terminated: terminated === true } }, + frame: + terminated === "thread_unloaded" + ? { + id: 4, + error: { code: -32600, message: `thread not found: ${BG_NATIVE_THREAD}` }, + } + : { id: 4, result: { terminated: terminated === true } }, }, - ...(terminated !== true + ...(terminated === false || stillRunning ? [ { type: "expect_outbound" as const, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 2256f4ff9e99..8dad9de3929f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2054,6 +2054,13 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi Effect.catchTags({ CodexAppServerProcessExitedError: () => Effect.succeed({ terminated: true }), CodexAppServerInputStreamEndedError: () => Effect.succeed({ terminated: true }), + // The thread is unloaded, as Codex does a minute after a + // settle or archive unsubscribes it. Unloading kills the + // thread's terminals, so nothing is left to stop. + CodexAppServerRequestError: (error) => + error.code === -32600 && error.errorMessage.startsWith("thread not found:") + ? Effect.succeed({ terminated: true }) + : Effect.fail(error), }), ); const result = yield* decodeCodexBackgroundTerminalTerminateResponse(response); From 7812230572f2d7e042d8d14ada2381fc71377560 Mon Sep 17 00:00:00 2001 From: Simone Date: Mon, 5 Oct 2026 12:14:33 +0200 Subject: [PATCH 06/21] fix(server): subagents no longer inherit parent pull-request links (#14918) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../Orchestrator.control-reads.test.ts | 90 +++++++++++++++++++ .../orchestration-v2/SubagentProjection.ts | 2 + 2 files changed, 92 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index ad41ae6ffde6..1a02b71982fe 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -534,3 +534,93 @@ it.effect("settles only the stopped run's background work, once", () => ]); }).pipe(Effect.provide(testLayer)), ); + +it.effect("keeps delegated child pull-request links independent of the parent", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const parentThreadId = ThreadId.make("thread:parent-pr"); + const projectId = ProjectId.make("project:parent-pr"); + const parentPullRequest = { + projectId, + repository: "pingdotgg/t3code", + number: 123, + url: "https://github.com/pingdotgg/t3code/pull/123", + }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-parent-pr"), + threadId: parentThreadId, + projectId, + title: "Parent with a linked PR", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/parent-pr", + worktreePath: "/repo-worktree", + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("link-parent-pr"), + threadId: parentThreadId, + linkedPullRequest: parentPullRequest, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-parent-pr"), + threadId: parentThreadId, + messageId: MessageId.make("message:parent-pr"), + text: "Delegate a review", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const parent = yield* projections.getThreadProjection(parentThreadId); + const parentRun = parent.runs[0]!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate-parent-pr"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Review the changes", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + const updatedParent = yield* projections.getThreadProjection(parentThreadId); + const childThreadId = updatedParent.subagents[0]!.childThreadId!; + const child = yield* projections.getThreadProjection(childThreadId); + assert.isNull(child.thread.linkedPullRequest); + assert.deepEqual(child.thread.pullRequests, []); + assert.equal(child.thread.branch, parent.thread.branch); + assert.equal(child.thread.worktreePath, parent.thread.worktreePath); + assert.equal(child.thread.lineage.parentThreadId, parentThreadId); + + const childPullRequest = { + ...parentPullRequest, + number: 456, + url: "https://github.com/pingdotgg/t3code/pull/456", + }; + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("link-child-pr"), + threadId: childThreadId, + linkedPullRequest: childPullRequest, + }); + const linkedChild = yield* projections.getThreadProjection(childThreadId); + assert.deepEqual(linkedChild.thread.linkedPullRequest, childPullRequest); + assert.deepEqual( + linkedChild.thread.pullRequests?.map((link) => link.number), + [456], + ); + const parentAfterChildLink = yield* projections.getThreadProjection(parentThreadId); + assert.deepEqual(parentAfterChildLink.thread.linkedPullRequest, parentPullRequest); + assert.deepEqual(parentAfterChildLink.thread.pullRequests, parent.thread.pullRequests); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index fa912dfda0ba..f28dc1aa4be5 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -57,6 +57,8 @@ export function makeSubagentChildThread(input: { creationSource: input.creationSource, id: input.childThreadId, title: input.title, + linkedPullRequest: null, + pullRequests: [], historyOrigin: undefined, providerInstanceId: input.providerInstanceId, modelSelection: input.modelSelection, From 7ffa2184a7cfc78d870bf6cbfa4064c51868aef2 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 5 Oct 2026 09:12:34 -0300 Subject: [PATCH 07/21] fix(prs): queue fast actions and close batches by dragging (#15851) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestService.test.ts | 68 ++++++++ .../src/pullRequest/PullRequestService.ts | 14 +- .../src/components/Sidebar.pointer.test.ts | 8 +- apps/web/src/components/Sidebar.pointer.ts | 3 +- .../components/pullRequest/PullRequestRow.tsx | 16 +- .../pullRequest/PullRequestSpeedActions.tsx | 54 +++---- .../pullRequest/pullRequestList.logic.test.ts | 25 +++ .../pullRequest/pullRequestList.logic.ts | 12 +- .../pullRequest/usePullRequestActions.ts | 68 +++++++- apps/web/src/routes/_chat.pull-requests.tsx | 133 +++++++++++++-- docs/user/source-control.md | 4 + .../src/state/pullRequests.test.ts | 153 ++++++++++++++++++ .../client-runtime/src/state/pullRequests.ts | 59 ++++++- 13 files changed, 550 insertions(+), 67 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d248d51d9855..14785b3f8e6b 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2,9 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -3348,6 +3350,72 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect.each(["list", "listStats"] as const)( + "retries a replacement %s read that joins a canceled lookup", + (operation) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stopping = yield* Deferred.make(); + const finishShutdown = yield* Deferred.make(); + let hostCalls = 0; + const lookup = Effect.gen(function* () { + if (++hostCalls !== 1) return; + yield* Deferred.succeed(started, undefined); + return yield* Effect.never.pipe( + Effect.onInterrupt(() => + Deferred.succeed(stopping, undefined).pipe( + Effect.andThen(Deferred.await(finishShutdown)), + ), + ), + ); + }); + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + lookup.pipe( + Effect.as({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }), + ), + listChangeRequestStats: () => + lookup.pipe( + Effect.as([{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]), + ), + }), + ], + }); + const read = + operation === "list" + ? service + .list({ state: "open" }) + .pipe(Effect.map((result) => result.entries.map((entry) => entry.number))) + : service + .listStats({ + refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], + }) + .pipe(Effect.map((result) => result.stats.map((stat) => stat.number))); + const original = yield* Effect.forkScoped(read); + yield* Deferred.await(started); + const cancellation = yield* Effect.forkScoped(Fiber.interrupt(original)); + yield* Deferred.await(stopping); + // Join the old entry while its lookup is still finishing cancellation. + const replacement = yield* Effect.forkScoped(read, { startImmediately: true }); + yield* Deferred.succeed(finishShutdown, undefined); + yield* Fiber.join(cancellation); + const canceled = yield* Fiber.await(original); + assert.isTrue(Exit.isFailure(canceled)); + if (Exit.isFailure(canceled)) assert.isTrue(Cause.hasInterruptsOnly(canceled.cause)); + assert.deepStrictEqual(yield* Fiber.join(replacement), [1]); + assert.strictEqual(hostCalls, 2); + }), +); + it.effect("shares one cold viewer lookup across distinct concurrent lists", () => Effect.gen(function* () { let viewerCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index afe2d9683cad..a5a1a5b01d62 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/shared/sourceControl"; import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -2887,7 +2888,12 @@ export const make = Effect.gen(function* () { ? null : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), ]); - return Cache.get(listCache, key); + // A replacement reader can join a lookup still finishing its previous reader's cancellation. + return Cache.get(listCache, key).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Cache.get(listCache, key) : Effect.failCause(cause), + ), + ); }; const checksCache = yield* Cache.makeWith( @@ -3134,7 +3140,11 @@ export const make = Effect.gen(function* () { } if (missing.size === 0) return { stats: held }; const key = statsBatchKey(missing.values()); - const { result, at } = yield* Cache.get(listStatsCache, key); + const { result, at } = yield* Cache.get(listStatsCache, key).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Cache.get(listStatsCache, key) : Effect.failCause(cause), + ), + ); for (const [key, ref] of missing) { const stat = result.stats.find( (stat) => diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index 3a54498c2304..0c171313612e 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -166,9 +166,9 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onCancel).not.toHaveBeenCalled(); }); - it("suppresses a delayed release click after cancellation, then accepts the next click", () => { + it.each([false, true])("suppresses a cancelled release click, started=%s", (started) => { const drag = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); vi.advanceTimersByTime(1000); const releaseClick = new Event("click"); @@ -182,9 +182,9 @@ describe("sidebar pointer lifecycle", () => { expect(nextPropagation).not.toHaveBeenCalled(); }); - it("allows the next click when the cancelled release happened outside the document", () => { + it.each([false, true])("allows a fresh click after an outside release, started=%s", (started) => { const drag = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); document.dispatchEvent(pointer("pointerdown")); const click = new Event("click"); diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index 5ae83901ed1e..23f95481485d 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -137,7 +137,8 @@ export class SidebarPointerSensor { // Cancellation can precede release by an arbitrary amount of time. Consume // that release click, or let a fresh pointerdown end suppression if release // happened outside the document. Ordinary clicks never install this guard. - if (!aborted) { + if (!aborted || cancelled) { + this.document.addEventListener("click", this.suppressClick, { capture: true }); this.document.addEventListener("pointerdown", this.clearClickSuppression, { capture: true }); } try { diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 1f4de90f55b8..29bfa3702d9d 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -91,6 +91,9 @@ function PullRequestRowImpl({ onSelect, speedMode, onActed, + closing = false, + sweeping = false, + onCloseSweepStart, }: { entry: EnvironmentPullRequestEntry; selected: boolean; @@ -110,12 +113,16 @@ function PullRequestRowImpl({ onSelect: (entry: PullRequestRowTarget) => void; speedMode: boolean; onActed: (result: PullRequestSpeedActionResult) => void; + closing?: boolean; + sweeping?: boolean; + onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return (
{entry.state !== "merged" && entry.provider === "github" ? ( - + ) : null}
); diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index 613928733d37..ee1bc5dcc02c 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -1,8 +1,4 @@ import type { PullRequestAction } from "@t3tools/contracts"; -import { Effect } from "effect"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; import { useUiStateStore } from "~/uiStateStore"; import { Button } from "../ui/button"; import { Spinner } from "../ui/spinner"; @@ -25,10 +21,16 @@ export function PullRequestSpeedActions({ entry, visible, onActed, + closing = false, + sweeping = false, + onCloseSweepStart, }: { entry: EnvironmentPullRequestEntry; visible: boolean; onActed: (result: PullRequestSpeedActionResult) => void; + closing?: boolean; + sweeping?: boolean; + onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; }) { const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( entry.environmentId, @@ -44,32 +46,7 @@ export function PullRequestSpeedActions({ environmentId: entry.environmentId, reference, onSuccess: (action) => onActed({ entry, action }), - resolveMergeMethod: async () => { - const target = { environmentId: entry.environmentId, input: reference }; - const detailAtom = pullRequestEnvironment.detail({ - ...target, - input: { ...reference, allowStale: false }, - }); - appAtomRegistry.refresh(detailAtom); - const detail = await Effect.runPromise( - AtomRegistry.getResult(appAtomRegistry, detailAtom, { suspendOnWaiting: true }), - ); - if ( - detail.state !== "open" || - detail.isDraft || - !detail.capabilities.actions.includes("merge") || - !detail.viewerPermissions.actions.includes("merge") - ) { - throw new Error("This pull request cannot be merged."); - } - if (detail.capabilities.stackActions) { - const stackAtom = pullRequestStackAtom(target); - appAtomRegistry.refresh(stackAtom); - const stack = await Effect.runPromise( - AtomRegistry.getResult(appAtomRegistry, stackAtom, { suspendOnWaiting: true }), - ); - if (stack !== null) throw new Error("Open this pull request to merge its stack."); - } + resolveMergeMethod: (detail) => { const allowed = detail.capabilities.mergeMethods.filter( (method) => detail.mergeCapabilities[method], ); @@ -89,12 +66,14 @@ export function PullRequestSpeedActions({ : entry.isDraft ? (["close", "ready"] as const) : (["close", "merge"] as const); + const busy = actionPending || closing || sweeping; return (
{actions.map((action) => { const label = ACTIONS[action].label; @@ -106,19 +85,26 @@ export function PullRequestSpeedActions({ + {onLocalConfiguration ? ( + + ) : null}
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx index 1a0f9276c5af..d112af1fed03 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -239,6 +239,74 @@ describe("AddProviderInstanceDialog environment routing", () => { ).not.toBeNull(); }); + it("creates a local command in the selected environment without a sign-in step", async () => { + const onOpenChange = vi.fn(); + let tree = render(onOpenChange); + const search = visitElements( + tree, + (element) => + typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep", + ); + (search!.props.onLocalConfiguration as () => void)(); + tree = render(onOpenChange); + (findByChildren(tree, "Next").props.onClick as () => void)(); + tree = render(onOpenChange); + expect(findByChildren(tree, "Executable is required.")).not.toBeNull(); + expect(settingsHooks.mutate).not.toHaveBeenCalled(); + + const configuration = visitElements( + tree, + (element) => element.props.idPrefix === "add-provider-acpRegistry-manual", + ); + const commandArgs = ["--profile", "acp", " literal $(value) ; ", ""]; + (configuration!.props.onChange as (value: Record) => void)({ + source: "local", + commandPath: "dsh", + commandArgs, + }); + const environmentEditor = visitElements( + tree, + (element) => + typeof element.type === "function" && element.type.name === "ProviderEnvironmentSection", + ); + const environment = [{ name: "DSH_PROFILE", value: "work", sensitive: false }]; + (environmentEditor!.props.onChange as (value: typeof environment) => void)(environment); + tree = render(onOpenChange); + (findByChildren(tree, "Next").props.onClick as () => void)(); + tree = render(onOpenChange); + const label = visitElements(tree, (element) => element.props.id === "add-provider-label"); + (label!.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: "Deepseek Harness" }, + }); + tree = render(onOpenChange); + (findByChildren(tree, "Add instance").props.onClick as () => void)(); + await Promise.resolve(); + await Promise.resolve(); + + expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId); + expect(settingsHooks.mutate).toHaveBeenCalledWith({ + operation: "create", + instanceId: "acpRegistry_deepseek_harness", + instance: { + driver: "acpRegistry", + enabled: true, + displayName: "Deepseek Harness", + config: { source: "local", commandPath: "dsh", commandArgs }, + environment, + }, + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + tree = render(onOpenChange); + expect( + visitElements( + tree, + (element) => + typeof element.type === "function" && + element.type.name === "ProviderWizardAuthenticationStep", + ), + ).toBeNull(); + }); + it("keeps the dialog open when the atomic upsert fails", async () => { settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); const onOpenChange = vi.fn(); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index 20d575c1cc58..aecb2c723d05 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -7,6 +7,7 @@ const ACP_REGISTRY_IDENTITY_STEP = 1; export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const; export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const; +export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const; export interface ProviderIdentityDraft { readonly label: string; @@ -65,7 +66,8 @@ export function isConfiguredAcpRegistryAgent( ) { return false; } - return (instance.config as Record).agentId === agentId; + const config = instance.config as Record; + return config.source !== "local" && config.agentId === agentId; }); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 1288d990b991..0d137c89dec3 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,6 +13,7 @@ import { ProviderDriverKind, type EnvironmentId, type ProviderInstanceConfig, + type ProviderInstanceEnvironmentVariable, } from "@t3tools/contracts"; import { @@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza import { ADD_PROVIDER_WIZARD_STEPS, ACP_REGISTRY_WIZARD_STEPS, + LOCAL_ACP_WIZARD_STEPS, deriveAvailableInstanceId, resolveAcpRegistryWizardNavigation, resolveWizardNavigation, @@ -51,6 +53,7 @@ import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep"; import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; +import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; /** * Normalize a user-provided label into a slug suffix for the instance id. @@ -118,6 +121,9 @@ export function AddProviderInstanceDialog({ {}, ); const [selectedAcp, setSelectedAcp] = useState(null); + const [localEnvironment, setLocalEnvironment] = useState< + ReadonlyArray + >([]); const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false); const [isRegistryLoading, setIsRegistryLoading] = useState(false); const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false); @@ -170,13 +176,23 @@ export function AddProviderInstanceDialog({ const previewLabel = label.trim() || `${driverOption.label} Workspace`; const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; + const isLocalAcp = isAcpRegistry && isManualAcpConfiguration && configDraft.source === "local"; + const localCommandPath = + typeof configDraft.commandPath === "string" ? configDraft.commandPath.trim() : ""; const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : ""; - const acpSelectionError = - selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) + const acpSelectionError = isLocalAcp + ? localCommandPath.length > 0 + ? null + : "Executable is required." + : selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) ? null : "Select an ACP or configure one manually."; const wizardStepSummaries = isAcpRegistry - ? ([selectedAcp?.name ?? (manualAgentId || null), previewLabel, null] as const) + ? ([ + isLocalAcp ? "Local ACP command" : (selectedAcp?.name ?? (manualAgentId || null)), + previewLabel, + null, + ] as const) : ([driverOption.label, previewLabel, null] as const); const setConfigDraft = (config: Record | undefined) => { setConfigByDriver((existing) => { @@ -201,7 +217,7 @@ export function AddProviderInstanceDialog({ if (navigation.kind === "blocked") { setHasAttemptedSubmit(true); } - if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) { + if (isAcpRegistry && !isLocalAcp && navigation.kind === "navigate" && navigation.step === 2) { void handleSave(); return; } @@ -211,10 +227,15 @@ export function AddProviderInstanceDialog({ const navigateToStep = (requestedStep: number) => { applyWizardNavigation( isAcpRegistry - ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { - instanceIdError, - selectionError: acpSelectionError, - }) + ? isLocalAcp + ? resolveWizardNavigation(wizardStep, requestedStep, LOCAL_ACP_WIZARD_STEPS.length, { + instanceIdError, + prerequisite: { step: 0, error: acpSelectionError }, + }) + : resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { + instanceIdError, + selectionError: acpSelectionError, + }) : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { instanceIdError, }), @@ -268,6 +289,24 @@ export function AddProviderInstanceDialog({ setHasAttemptedSubmit(false); }; + const handleLocalAcpConfiguration = () => { + setDriver(ACP_REGISTRY_DRIVER_KIND); + setSelectedAcp(null); + setIsManualAcpConfiguration(true); + setConfigByDriver((existing) => ({ + ...existing, + [ACP_REGISTRY_DRIVER_KIND]: { source: "local", commandArgs: [] }, + })); + setIdentityByDriver((existing) => + updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, { + label: "Local ACP", + instanceIdOverride: null, + }), + ); + setLocalEnvironment([]); + setHasAttemptedSubmit(false); + }; + const handleSave = async () => { if (isSaving || createdInstanceId) return; setHasAttemptedSubmit(true); @@ -286,6 +325,7 @@ export function AddProviderInstanceDialog({ ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), ...(hasConfig ? { config } : {}), + ...(isLocalAcp && localEnvironment.length > 0 ? { environment: localEnvironment } : {}), }; // `ProviderInstanceId.make` revalidates the slug; we've already checked // it via `validateInstanceId`, but going through the brand constructor @@ -309,7 +349,7 @@ export function AddProviderInstanceDialog({ return; } onCreated?.(brandedId); - if (isAcpRegistry) { + if (isAcpRegistry && !isLocalAcp) { setCreatedInstanceId(brandedId); setIsSaving(false); setWizardStep(2); @@ -344,7 +384,7 @@ export function AddProviderInstanceDialog({ currentStep={wizardStep} summaries={wizardStepSummaries} instanceIdError={instanceIdError} - steps={ACP_REGISTRY_WIZARD_STEPS} + steps={isLocalAcp ? LOCAL_ACP_WIZARD_STEPS : ACP_REGISTRY_WIZARD_STEPS} disabled={isSaving || isPreparingRegistryAgent || createdInstanceId !== null} identityStep={1} prerequisite={{ step: 0, error: acpSelectionError }} @@ -434,9 +474,13 @@ export function AddProviderInstanceDialog({
-

Enter manually

+

+ {isLocalAcp ? "Local ACP command" : "Enter manually"} +

- Enter an official registry ID and any local executable or auth override. + {isLocalAcp + ? `Run an installed ACP executable on ${environmentLabel}.` + : "Enter an official registry ID and any local executable or auth override."}

) : ( )} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 415ebb97c029..9965925ad27e 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: { ); } -function ProviderEnvironmentSection(props: { +export function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; }) { @@ -717,8 +717,16 @@ export function ProviderInstanceCard({ driverKind={driverKind ?? instance.driver} displayName={displayName} accentColor={accentColor} - acpRegistryAgentId={readConfigString(instance.config, "agentId") ?? undefined} - acpRegistryIconUrl={readConfigString(instance.config, "registryIconUrl") ?? undefined} + acpRegistryAgentId={ + readConfigString(instance.config, "source") === "local" + ? undefined + : (readConfigString(instance.config, "agentId") ?? undefined) + } + acpRegistryIconUrl={ + readConfigString(instance.config, "source") === "local" + ? undefined + : (readConfigString(instance.config, "registryIconUrl") ?? undefined) + } showBadge={Boolean(accentColor)} className="size-5" iconClassName="size-4 text-foreground/80" diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 5737e47f2a13..6edeedb30740 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => { expect(acpRegistry).toBeDefined(); expect(acpRegistry?.hasDefaultInstance).toBe(false); expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ + "source", "agentId", "commandPath", "authMethodId", ]); }); + it("shows the local executable without registry identity or authentication fields", () => { + const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + expect( + deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), + ).toEqual(["source", "commandPath"]); + }); + it("derives a select control with its choices for the Antigravity sign-in method", () => { const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; expect(antigravity).toBeDefined(); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 94372b97c868..dfb35facbd60 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { @@ -9,8 +9,10 @@ import type { ProviderSettingsFormOption, ProviderSettingsFormSchemaAnnotation, } from "@t3tools/contracts"; +import { PlusIcon, XIcon } from "lucide-react"; import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -76,7 +78,10 @@ function readFieldBooleanDefault( export function deriveProviderSettingsFields( definition: ProviderClientDefinition, + value?: unknown, ): ReadonlyArray { + const isLocalAcp = + definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); const orderedKeys = new Map( (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), @@ -95,6 +100,7 @@ export function deriveProviderSettingsFields( const fieldSchema = definition.settingsSchema.fields[key]!; const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); if (formAnnotation.hidden) return []; + if (isLocalAcp && key !== "source" && key !== "commandPath") return []; const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); @@ -102,11 +108,20 @@ export function deriveProviderSettingsFields( { key, control: formAnnotation.control ?? "text", - label: annotatedTitle ?? titleizeFieldKey(key), - ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}), - ...(formAnnotation.placeholder !== undefined - ? { placeholder: formAnnotation.placeholder } - : {}), + label: + isLocalAcp && key === "commandPath" + ? "Executable" + : (annotatedTitle ?? titleizeFieldKey(key)), + ...(isLocalAcp && key === "commandPath" + ? { description: "Executable name or path on this environment." } + : annotatedDescription !== undefined + ? { description: annotatedDescription } + : {}), + ...(isLocalAcp && key === "commandPath" + ? { placeholder: "e.g. dsh" } + : formAnnotation.placeholder !== undefined + ? { placeholder: formAnnotation.placeholder } + : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } @@ -119,6 +134,112 @@ export function deriveProviderSettingsFields( }); } +let commandArgumentDraftId = 0; +const makeCommandArgumentDraftRow = (value: string) => ({ + id: `provider-argument-${commandArgumentDraftId++}`, + value, +}); + +function commandArgumentsEqual(left: ReadonlyArray, right: ReadonlyArray) { + return left.length === right.length && left.every((argument, index) => argument === right[index]); +} + +function ProviderCommandArguments({ + value, + onChange, +}: Pick) { + const args = useMemo(() => { + const configured = + value !== null && typeof value === "object" + ? (value as Record).commandArgs + : undefined; + return Array.isArray(configured) + ? configured.filter((argument): argument is string => typeof argument === "string") + : []; + }, [value]); + const [rows, setRows] = useState(() => args.map(makeCommandArgumentDraftRow)); + const rowsRef = useRef(rows); + const previousArgsRef = useRef(args); + const lastPublishedArgsRef = useRef | undefined>(undefined); + + useEffect(() => { + const previousArgs = previousArgsRef.current; + const lastPublishedArgs = lastPublishedArgsRef.current; + previousArgsRef.current = args; + lastPublishedArgsRef.current = undefined; + if ( + commandArgumentsEqual(previousArgs, args) || + (lastPublishedArgs !== undefined && commandArgumentsEqual(lastPublishedArgs, args)) + ) + return; + const nextRows = args.map(makeCommandArgumentDraftRow); + rowsRef.current = nextRows; + setRows(nextRows); + }, [args]); + + const updateArguments = (nextRows: typeof rows) => { + rowsRef.current = nextRows; + setRows(nextRows); + const next = nextRows.map((row) => row.value); + lastPublishedArgsRef.current = next; + const config = + value !== null && typeof value === "object" ? { ...(value as Record) } : {}; + onChange({ ...config, commandArgs: next }); + }; + + return ( + updateArguments([...rowsRef.current, makeCommandArgumentDraftRow("")])} + > + + Add argument + + } + > + {rows.length > 0 ? ( +
+ {rows.map((argument, index) => ( +
+ + updateArguments( + rowsRef.current.map((current) => + current.id === argument.id ? { ...current, value: next } : current, + ), + ) + } + aria-label={`Argument ${index + 1}`} + spellCheck={false} + /> + +
+ ))} +
+ ) : null} +
+ ); +} + function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; const value = (config as Record)[key]; @@ -411,7 +532,12 @@ export function ProviderSettingsForm({ variant, onChange, }: ProviderSettingsFormProps) { - const fields = useMemo(() => deriveProviderSettingsFields(definition), [definition]); + const fields = useMemo( + () => deriveProviderSettingsFields(definition, value), + [definition, value], + ); + const isLocalAcp = + definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; if (fields.length === 0) { return null; @@ -429,6 +555,7 @@ export function ProviderSettingsForm({ onChange={onChange} /> ))} + {isLocalAcp ? : null} ); } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 41fd9efb5da4..cc8fe347c124 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({ } if (row.driver !== ProviderDriverKind.make("acpRegistry")) return; + if (providerConfigString(row.instance.config, "source") === "local") return; const agentId = providerConfigString(row.instance.config, "agentId"); if (agentId === null) return; diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a5bb26e691c0..4ff23880ade6 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => { }); describe("provider icon metadata", () => { + it("clears stale registry branding when an instance switches to a local command", () => { + const instanceId = ProviderInstanceId.make("custom-acp"); + const driver = ProviderDriverKind.make("acpRegistry"); + const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; + const snapshots = deriveProviderInstanceEntries([ + { ...provider({ provider: driver, instanceId }), iconUrl }, + ]); + const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; + const brandedEntries = applyProviderInstanceSettings(snapshots, { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + providers: {} as never, + }); + expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); + expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); + + const [localEntry] = applyProviderInstanceSettings(brandedEntries, { + providerInstances: { + [instanceId]: { + driver, + enabled: false, + config: { ...registryConfig, source: "local", commandPath: "dsh" }, + }, + }, + providers: {} as never, + }); + expect(localEntry?.acpRegistryAgentId).toBeUndefined(); + expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); + expect(localEntry?.enabled).toBe(false); + expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); + + const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + providers: {} as never, + }); + expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); + expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); + expect(restoredEntry?.enabled).toBe(true); + }); + it("retains server-published registry icons without local settings", () => { const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; const [entry] = deriveProviderInstanceEntries([ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index ae2ea5699bf5..60630c5f826b 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -210,6 +210,9 @@ export function applyProviderInstanceSettings( explicitInstance.config !== null && typeof explicitInstance.config === "object" ? (explicitInstance.config as Readonly>) : null; + if (config?.source === "local") { + return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined }; + } const agentId = config?.agentId; const iconUrl = config?.registryIconUrl; return { diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index adf4e17af631..9f179e88da0f 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -1,6 +1,6 @@ -# ACP Registry +# ACP providers -T3 Code can run coding agents from the official +T3 Code can run installed ACP commands or coding agents from the official [ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task delegation. @@ -23,6 +23,17 @@ work through the same generic integration. Search only shows agents that can run on the connected server. Registry agents are third-party code; review an agent's source and license before adding it. +## Add a local command + +In **Settings → Providers → Add provider**, select **Local ACP command**. Enter the executable +name or path on the selected environment, add one literal argument per row, and choose a display +name. For DeepSeek Harness, use `dsh` with arguments `--profile` and `acp`. + +Local commands require no registry entry or download. Environment overrides apply to the command; +credentials and skills remain managed by the installed agent or its wrapper. Models and reasoning +options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly +without expanding shell expressions. + ## Where agents run Registry agents always run on the machine that hosts your T3 Code server. That stays true when you diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 082459efc3df..c2720d0a371f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -871,6 +871,19 @@ export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPr export const AcpRegistrySettings = makeProviderSettingsSchema( { + source: Schema.Literals(["registry", "local"]).pipe( + Schema.withDecodingDefault(Effect.succeed("registry")), + Schema.annotateKey({ + title: "ACP source", + providerSettingsForm: { + control: "select", + options: [ + { value: "registry", label: "ACP Registry" }, + { value: "local", label: "Local command" }, + ], + }, + }), + ), enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), @@ -888,10 +901,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( Schema.annotateKey({ title: "Executable override", description: - "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.", + "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, }), ), + commandArgs: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), authMethodId: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ @@ -911,7 +928,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( ), }, { - order: ["agentId", "commandPath", "authMethodId"], + order: ["source", "agentId", "commandPath", "authMethodId"], }, ); export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; From 3e6b45028ceec5820dacb37dc3852470ebdc9411 Mon Sep 17 00:00:00 2001 From: Rahul Mishra Date: Mon, 5 Oct 2026 22:47:13 +0530 Subject: [PATCH 21/21] fix(server): honor submodule settings when creating worktrees (#15594) --- apps/server/src/git/GitManager.ts | 20 ++++++++++++++++++++ apps/server/src/git/GitWorkflowService.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2829502c8cbd..04433f26e533 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -35,6 +35,8 @@ import { type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, } from "@t3tools/contracts"; import { hasProjectSettingsOverrides, @@ -103,6 +105,10 @@ interface SourceControlTextGenerationSettings { export class GitManager extends Context.Service< GitManager, { + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + options?: GitVcsDriver.CreateWorktreeOptions, + ) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; @@ -727,6 +733,19 @@ export const make = Effect.gen(function* () { ); return resolveProjectSettings(settings, projectId).settings; }); + const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn( + "GitManager.createWorktree", + )(function* (input, options) { + const submodules = + options?.submodules !== undefined + ? options.submodules + : yield* projectSettingsFor(input).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ); + return yield* gitCore.createWorktree(input, { ...options, submodules }); + }); + const readRepositoryInstructions = (cwd: string, fileName: string) => Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); @@ -2849,6 +2868,7 @@ export const make = Effect.gen(function* () { ); return GitManager.of({ + createWorktree, localStatus, remoteStatus, status, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 912e0c1f4620..bc7f2d9fb2ea 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -347,7 +347,7 @@ export const make = Effect.gen(function* () { ), createWorktree: (input, options) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( - Effect.andThen(git.createWorktree(input, options)), + Effect.andThen(gitManager.createWorktree(input, options)), ), listLocalBranchNames: (cwd) => ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe(