From c31db33551f8c4b7b497ea86e742ad4c40412323 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 7 Sep 2026 23:52:28 -0300 Subject: [PATCH 01/14] fix(desktop): resolve local media linked from remote threads (#10619) Pylon adaptation: the new fallback tests share an import block with Pylon's existing asset URL metadata test; no behavior differs from upstream. Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3) --- apps/server/src/server.test.ts | 37 +++++ apps/server/src/ws.ts | 6 +- apps/web/src/state/assets.ts | 20 ++- .../client-runtime/src/state/assets.test.ts | 139 +++++++++++++++++- packages/client-runtime/src/state/assets.ts | 54 ++++++- packages/client-runtime/src/state/runtime.ts | 7 +- 6 files changed, 250 insertions(+), 13 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b2d02ea5a5..eb7138cab5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5599,6 +5599,43 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves absolute host media without a local thread and rejects relative media", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-host-media-" }); + const wsUrl = yield* getWsServerUrl("/ws"); + const threadId = ThreadId.make("thread-on-another-environment"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + for (const [name, mimeType] of [ + ["screenshot.png", "image/png"], + ["recording.mp4", "video/mp4"], + ] as const) { + const filePath = path.join(directory, name); + yield* fileSystem.writeFileString(filePath, "host media bytes"); + const issued = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "media-file", threadId, path: filePath }, + }); + const response = yield* HttpClient.get(issued.relativeUrl); + assert.equal(response.status, 200); + assert.equal(response.headers["content-type"], mimeType); + assert.equal(yield* response.text, "host media bytes"); + + const error = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "media-file", threadId, path: name }, + }).pipe(Effect.flip); + assert.equal(error._tag, "AssetWorkspaceContextNotFoundError"); + } + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads image bytes through a signed URL issued by websocket rpc", () => Effect.gen(function* () { const config = yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a0e6df74a3..acfb4f836f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -2661,9 +2662,12 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.assetsCreateUrl, Effect.gen(function* () { + const path = yield* Path.Path; + // An absolute media path can be linked from a thread on another environment. if ( input.resource._tag === "attachment" || - input.resource._tag === "native-app-icon" + input.resource._tag === "native-app-icon" || + (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) ) { return yield* issueAssetUrl({ resource: input.resource }); } diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index d1ef71f866..672e2d8867 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -2,12 +2,30 @@ import { createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily, } from "@t3tools/client-runtime/state/assets"; +import { Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { isElectron } from "../env"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; -export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +const localMediaEnvironment = Atom.make((get) => { + if (!isElectron) return null; + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) return null; + const connection = get(environmentSession.preparedConnectionValueAtom(environmentId)); + // The session's bootstrap config clears on disconnect and refreshes on reconnect. + const config = get(environmentSession.initialConfigValueAtom(environmentId)); + return connection._tag === "None" || config === null + ? null + : { environmentId, httpBaseUrl: connection.value.httpBaseUrl }; +}); + +export const assetEnvironment = createAssetEnvironmentAtoms( + connectionAtomRuntime, + localMediaEnvironment, +); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ imageCache: projectFaviconCache, diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index a1abe281e3..047244bf06 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,12 +1,33 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + type AssetCreateUrlResult, + AssetWorkspaceAssetNotFoundError, + AssetWorkspaceAssetInspectionError, + AssetWorkspaceContextNotFoundError, + EnvironmentAuthorizationError, + EnvironmentId, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Option from "effect/Option"; -import { createProjectFaviconCache } from "../projectFaviconCache.ts"; -import { describe, expect, it } from "@effect/vitest"; -import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; import * as Layer from "effect/Layer"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { createProjectFaviconCache } from "../projectFaviconCache.ts"; import { assetUrlStateFromResult, createAssetEnvironmentAtoms, @@ -38,6 +59,116 @@ describe("asset collection keys", () => { }); describe("createAssetEnvironmentAtoms", () => { + for (const scenario of [ + { name: "missing video", path: "/tmp/clip.mp4", fallback: true }, + { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true }, + { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true }, + { name: "inspection failure", path: "/tmp/clip.mp4", error: "inspection", fallback: true }, + { name: "foreign thread", path: "/tmp/clip.mp4", error: "context", fallback: true }, + { name: "remote success", path: "/tmp/clip.mp4", success: true }, + { name: "relative path", path: "clip.mp4" }, + { name: "no primary", path: "/tmp/clip.mp4", primary: "none" }, + { name: "primary reconnect", path: "/tmp/frame.png", primary: "reconnecting" }, + { name: "same environment", path: "/tmp/clip.mp4", primary: "same" }, + { name: "non-media", path: "/tmp/report.html" }, + { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" }, + ]) { + it.effect(`uses the correct environment for ${scenario.name}`, () => + Effect.gen(function* () { + const remoteId = EnvironmentId.make("remote"); + const localId = EnvironmentId.make("local"); + const resource = { + _tag: "media-file" as const, + threadId: ThreadId.make("foreign-thread"), + path: scenario.path, + }; + const error = + scenario.error === "auth" + ? new EnvironmentAuthorizationError({ + message: "denied", + requiredScope: "orchestration:read", + }) + : scenario.error === "inspection" + ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") }) + : scenario.error === "context" + ? new AssetWorkspaceContextNotFoundError({ resource }) + : new AssetWorkspaceAssetNotFoundError({ resource }); + const calls: EnvironmentId[] = []; + const supervisors = new Map(); + for (const environmentId of [remoteId, localId]) { + const client = { + [WS_METHODS.assetsCreateUrl]: () => { + calls.push(environmentId); + return environmentId === remoteId && !scenario.success + ? Effect.fail(error) + : Effect.succeed({ + relativeUrl: `/api/assets/${environmentId}/media`, + expiresAt: 999999, + }); + }, + } as unknown as WsRpcProtocolClient; + const session = { client } as RpcSession; + supervisors.set( + environmentId, + EnvironmentSupervisor.of({ + target: new PrimaryConnectionTarget({ + environmentId, + label: environmentId, + httpBaseUrl: `https://${environmentId}.test`, + wsBaseUrl: `wss://${environmentId}.test`, + }), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, + }), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }), + ); + } + const environments = EnvironmentRegistry.of({ + run: (id, effect) => + Effect.provideService(effect, EnvironmentSupervisor, supervisors.get(id)!), + followStream: (id, stream) => + Stream.provideService(stream, EnvironmentSupervisor, supervisors.get(id)!), + } as EnvironmentRegistry["Service"]); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const localTarget = { + environmentId: scenario.primary === "same" ? remoteId : localId, + httpBaseUrl: "https://local.test", + }; + const localEnvironment = Atom.make( + scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget, + ); + const assets = createAssetEnvironmentAtoms( + Atom.runtime(Layer.succeed(EnvironmentRegistry, environments)), + localEnvironment, + ); + const query = assets.createUrl({ environmentId: remoteId, input: { resource } }); + const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); + if (scenario.fallback || scenario.success) { + expect((yield* result).relativeUrl).toBe( + scenario.fallback + ? "https://local.test/api/assets/local/media" + : "/api/assets/remote/media", + ); + } else { + expect(yield* Effect.flip(result)).toEqual(error); + } + expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]); + if (scenario.primary === "reconnecting") { + registry.set(localEnvironment, localTarget); + expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media"); + expect(calls).toEqual([remoteId, remoteId, localId]); + } + }).pipe(Effect.scoped), + ); + } + it("keys asset URL queries by environment and resource", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< EnvironmentRegistry, diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 80dc731ba0..adccc17852 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,22 +1,28 @@ import { + type AssetCreateUrlInput, type AssetCreateUrlResult, type AssetImageDimensions, AssetResource, EnvironmentId, WS_METHODS, } from "@t3tools/contracts"; +import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import type { EnvironmentRegistry } from "../connection/registry.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { request } from "../rpc/client.ts"; import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { createEnvironmentQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; const ASSET_URL_STALE_TIME_MS = 5 * 60_000; @@ -90,14 +96,50 @@ export function assetUrlStateFromResult( } export function createAssetEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, + localMediaEnvironment?: Atom.Atom<{ + readonly environmentId: EnvironmentId; + readonly httpBaseUrl: string; + } | null>, ) { - const createUrl = createEnvironmentRpcQueryAtomFamily(runtime, { + const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) { + const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result); + if (Result.isSuccess(result)) return result.success; + const error = result.failure; + const resource = input.resource; + const local = localMediaEnvironment + ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment) + : null; + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + if ( + !local || + local.environmentId === supervisor.target.environmentId || + !( + error._tag === "AssetWorkspaceAssetNotFoundError" || + error._tag === "AssetWorkspaceAssetInspectionError" || + error._tag === "AssetWorkspaceContextNotFoundError" + ) || + resource._tag !== "media-file" || + !(resource.path.startsWith("/") || isWindowsAbsolutePath(resource.path)) || + mediaMimeTypeFromExtension(resource.path.slice(resource.path.lastIndexOf("."))) === null + ) + return yield* error; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const asset = yield* registry.run( + local.environmentId, + request(WS_METHODS.assetsCreateUrl, input), + ); + // Callers resolve against the thread's server, so preserve the local server's origin. + return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href }; + }); + const createUrl = createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:assets:create-url", - tag: WS_METHODS.assetsCreateUrl, + execute, staleTimeMs: ASSET_URL_STALE_TIME_MS, idleTtlMs: ASSET_URL_IDLE_TTL_MS, refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, + refreshTrigger: ({ input }) => + input.resource._tag === "media-file" ? localMediaEnvironment : undefined, }); const createUrlsFamily = Atom.family((key: string) => { const [environmentId, resources] = parseAssetCollectionKey(key); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 3e61909ee7..46cb9fa0f1 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -482,7 +482,12 @@ export function followStreamInEnvironment( export function createEnvironmentQueryAtomFamily( runtime: Atom.AtomRuntime, - options: EnvironmentQueryAtomOptions, + options: EnvironmentQueryAtomOptions< + Input, + A, + E, + EnvironmentSupervisor | EnvironmentRegistry | AtomRegistry.AtomRegistry | R + >, ): (target: { readonly environmentId: EnvironmentIdType; readonly input: Input; From 2fd0870a48582eb0788e4852da0fdf418b7e76d3 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 8 Sep 2026 00:34:43 -0300 Subject: [PATCH 02/14] fix(preview): transfer recordings to the agent environment (#10572) Pylon adaptation: the new recording tests mock getUserMedia, which Pylon's tab capture uses, instead of upstream's getDisplayMedia capture trigger. Upstream's gesture-free capture-trigger test belongs to earlier capture work Pylon does not have and is not added. (cherry picked from commit 9e37f0c291974d084a59d7b9f165a1cb056e6043) --- .../src/mcp/PreviewAutomationBroker.test.ts | 43 ++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 24 ++++ .../src/mcp/toolkits/preview/handlers.test.ts | 127 +++++++++++++++++- .../src/mcp/toolkits/preview/handlers.ts | 116 ++++++++++++++-- apps/server/src/mcp/toolkits/preview/tools.ts | 6 +- apps/web/src/browser/browserRecording.test.ts | 43 ++++++ apps/web/src/browser/browserRecording.ts | 16 +++ .../web/src/browser/browserRecordingUpload.ts | 84 ++++++++++++ .../preview/PreviewAutomationHosts.tsx | 20 ++- .../preview/previewAutomationErrors.ts | 10 +- packages/contracts/src/previewAutomation.ts | 45 +++++++ 11 files changed, 517 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/browser/browserRecordingUpload.ts diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd7130..42f849f5ed 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -404,6 +404,49 @@ it.effect("classifies a remote non-editable target without collapsing it to exec ); }); +it.effect.each([ + "PreviewAutomationRecordingTransferError", + "PreviewAutomationRecordingDesktopUpdateRequiredError", + "PreviewAutomationRecordingTooLargeError", + "PreviewAutomationRecordingDeadlineExpiredError", +] as const)("preserves recording failure %s", (tag) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const remoteError = { + _tag: tag, + message: "remote recording details", + detail: { reason: "untrusted-reason", threadId: "untrusted-thread" }, + }; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + error: remoteError, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const error = yield* broker + .invoke({ + scope, + operation: "recordingStop", + input: {}, + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: tag, + threadId: scope.threadId, + }); + expect(error.cause).toBe(remoteError); + expect(error.message).toContain("remains on the desktop"); + expect(error.message).not.toContain("remote recording details"); + }), + ), +); + it.effect("distinguishes malformed remote failures", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 3e9bfaac26..d8f17973c2 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -7,6 +7,10 @@ import { PreviewAutomationMalformedResponseError, PreviewAutomationNoAvailableHostError, PreviewAutomationRemoteUnavailableError, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingDesktopUpdateRequiredError, + PreviewAutomationRecordingTooLargeError, + PreviewAutomationRecordingDeadlineExpiredError, PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, @@ -194,6 +198,26 @@ const classifyResponseError = ( cause: error, }; switch (error._tag) { + case "PreviewAutomationRecordingDesktopUpdateRequiredError": + return new PreviewAutomationRecordingDesktopUpdateRequiredError({ + threadId: context.threadId, + cause: error, + }); + case "PreviewAutomationRecordingTooLargeError": + return new PreviewAutomationRecordingTooLargeError({ + threadId: context.threadId, + cause: error, + }); + case "PreviewAutomationRecordingDeadlineExpiredError": + return new PreviewAutomationRecordingDeadlineExpiredError({ + threadId: context.threadId, + cause: error, + }); + case "PreviewAutomationRecordingTransferError": + return new PreviewAutomationRecordingTransferError({ + threadId: context.threadId, + cause: error, + }); case "PreviewAutomationNoAvailableHostError": return new PreviewAutomationNoAvailableHostError({ ...context, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts index 2c4e667464..a2a88e14fc 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; -import { normalizePreviewOpenInput } from "./handlers.ts"; +import { + createPendingAttachmentId, + parseThreadSegmentFromAttachmentId, +} from "../../../attachmentStore.ts"; +import * as ServerConfig from "../../../config.ts"; +import { claimPreviewRecording, normalizePreviewOpenInput } from "./handlers.ts"; describe("normalizePreviewOpenInput", () => { it("leaves an unstated visibility for the client preference to decide", () => { @@ -30,3 +41,115 @@ describe("normalizePreviewOpenInput", () => { }); }); }); + +describe("claimPreviewRecording", () => { + it.effect("overlapping and repeated claims return the same retained recording", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uploadedAttachmentId = createPendingAttachmentId(".webm"); + const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(pendingPath, "video!"); + const response = { + id: "desktop-recording", + tabId: "tab-1", + path: "/desktop/recording.webm", + mimeType: "video/webm", + sizeBytes: 6, + createdAt: "2026-09-07T00:00:00.000Z", + uploadedAttachmentId, + }; + const claim = claimPreviewRecording(ThreadId.make("thread-1"), response); + const [first, second] = yield* Effect.all([claim, claim], { concurrency: "unbounded" }); + expect(first).toEqual(second); + expect(yield* claim).toEqual(first); + expect(yield* fileSystem.readFileString(first.path)).toBe("video!"); + expect(yield* fileSystem.exists(pendingPath)).toBe(false); + const wrongThread = yield* claimPreviewRecording(ThreadId.make("thread-2"), response).pipe( + Effect.result, + ); + expect(wrongThread._tag).toBe("Failure"); + const wrongPath = yield* claimPreviewRecording(ThreadId.make("thread-1"), { + ...response, + uploadedAttachmentId: `../${uploadedAttachmentId}`, + }).pipe(Effect.result); + expect(wrongPath._tag).toBe("Failure"); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([6, 5])( + "claims only a complete uploaded recording (reported bytes: %s)", + (sizeBytes) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uploadedAttachmentId = createPendingAttachmentId(".webm"); + const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(pendingPath, "video!"); + const response = { + id: "desktop-recording", + tabId: "tab-1", + path: "/desktop/recording.webm", + mimeType: "video/webm", + sizeBytes, + createdAt: "2026-09-07T00:00:00.000Z", + uploadedAttachmentId, + }; + const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), response).pipe( + Effect.result, + ); + if (sizeBytes === 6) { + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") return; + expect(result.success.path).not.toBe(response.path); + expect(parseThreadSegmentFromAttachmentId(result.success.id)).toBe("thread-1"); + expect(yield* fileSystem.readFileString(result.success.path)).toBe("video!"); + expect(yield* fileSystem.exists(pendingPath)).toBe(false); + } else { + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure._tag).toBe("PreviewAutomationRecordingTransferError"); + expect(yield* fileSystem.exists(pendingPath)).toBe(true); + } + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect("reports an older desktop without returning its inaccessible path", () => + Effect.gen(function* () { + const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), { + id: "desktop-recording", + tabId: "tab-1", + path: "/desktop/recording.webm", + mimeType: "video/webm", + sizeBytes: 6, + createdAt: "2026-09-07T00:00:00.000Z", + }).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure._tag).toBe("PreviewAutomationRecordingDesktopUpdateRequiredError"); + expect(result.failure.message).toContain("Update the desktop app"); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); +}); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index b107b8450a..3cf3322cc6 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,16 +1,31 @@ import * as Effect from "effect/Effect"; -import type { - PreviewAutomationOperation, - PreviewAutomationOpenInput, +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PREVIEW_RECORDING_STOP_TIMEOUT_MS, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingDesktopUpdateRequiredError, PreviewAutomationRecordingArtifact, - PreviewAutomationRecordingStatus, - PreviewAutomationResizeResult, - PreviewAutomationSetColorSchemeResult, - PreviewAutomationSnapshot, - PreviewAutomationStatus, - PreviewTabId, + type ThreadId, + type PreviewAutomationOperation, + type PreviewAutomationOpenInput, + type PreviewAutomationRecordingStatus, + type PreviewAutomationResizeResult, + type PreviewAutomationSetColorSchemeResult, + type PreviewAutomationSnapshot, + type PreviewAutomationStatus, + type PreviewTabId, } from "@t3tools/contracts"; +import { + parseAttachmentUuid, + parseAttachmentFileExtension, + PENDING_ATTACHMENT_THREAD_SEGMENT, + toSafeThreadAttachmentSegment, +} from "../../../attachmentStore.ts"; +import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts"; +import * as ServerConfig from "../../../config.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; @@ -67,6 +82,79 @@ const invokeTargeted = ( return invoke(operation, operationInput, timeoutMs, tabId); }; +const UploadedRecordingArtifact = Schema.Struct({ + ...PreviewAutomationRecordingArtifact.fields, + uploadedAttachmentId: Schema.optional(Schema.String), +}); +const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact); + +export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(function* ( + threadId: ThreadId, + response: unknown, +) { + const artifact = yield* decodeUploadedRecordingArtifact(response).pipe( + Effect.mapError( + (cause) => + new PreviewAutomationRecordingTransferError({ + threadId, + cause, + }), + ), + ); + if (!artifact.uploadedAttachmentId) { + return yield* new PreviewAutomationRecordingDesktopUpdateRequiredError({ threadId }); + } + const config = yield* ServerConfig.ServerConfig; + const uuid = parseAttachmentUuid(artifact.uploadedAttachmentId); + const extension = parseAttachmentFileExtension(artifact.uploadedAttachmentId); + const threadSegment = toSafeThreadAttachmentSegment(threadId); + const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${uuid}-${extension}`; + if (!uuid || !extension || !threadSegment || artifact.uploadedAttachmentId !== pendingId) { + return yield* new PreviewAutomationRecordingTransferError({ + threadId, + }); + } + // The same completed upload can be returned to overlapping stop requests. + const finalId = `${threadSegment}-${uuid}-${extension}`; + const currentPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${pendingId}.${extension}`, + }); + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${finalId}.${extension}`, + }); + if (!currentPath || !finalPath) { + return yield* new PreviewAutomationRecordingTransferError({ threadId }); + } + const fileSystem = yield* FileSystem.FileSystem; + const validateFile = (filePath: string) => + fileSystem.stat(filePath).pipe( + Effect.filterOrFail( + (stat) => + stat.type === "File" && + Number(stat.size) === artifact.sizeBytes && + artifact.sizeBytes > 0 && + artifact.sizeBytes <= PROVIDER_SEND_TURN_MAX_FILE_BYTES, + () => new PreviewAutomationRecordingTransferError({ threadId }), + ), + ); + yield* Effect.gen(function* () { + yield* validateFile(currentPath); + yield* fileSystem.rename(currentPath, finalPath); + }).pipe( + // Another stop may already have claimed this exact upload for this thread. + Effect.catch((cause) => + cause._tag !== "PreviewAutomationRecordingTransferError" && cause.reason._tag === "NotFound" + ? validateFile(finalPath) + : Effect.fail(cause), + ), + Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })), + ); + const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact; + return { ...recording, id: finalId, path: finalPath }; +}); + const handlers = { preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => @@ -94,7 +182,15 @@ const handlers = { preview_recording_start: (input) => invokeTargeted("recordingStart", input ?? {}), preview_recording_stop: (input) => - invokeTargeted("recordingStop", input ?? {}), + Effect.gen(function* () { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const response = yield* invokeTargeted( + "recordingStop", + { ...input, transferToEnvironment: true }, + PREVIEW_RECORDING_STOP_TIMEOUT_MS, + ); + return yield* claimPreviewRecording(scope.threadId, response); + }), } satisfies Parameters[0]; const { preview_snapshot, ...standardHandlers } = handlers; diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 3d644a31d9..0429f441ff 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -19,10 +19,12 @@ import { PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as FileSystem from "effect/FileSystem"; import { Tool, Toolkit } from "effect/unstable/ai"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; +import * as ServerConfig from "../../../config.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, @@ -207,11 +209,11 @@ const PreviewRecordingStartTool = safeBrowserTool( const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: - "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.", + "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, - dependencies, + dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], }).annotate(Tool.Title, "Stop browser recording"), ); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 737aae00c4..17487d8e19 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -55,6 +55,7 @@ import { readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, + stopBrowserRecordingForUpload, } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; @@ -143,6 +144,48 @@ describe("browser recording", () => { expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); }); + it("saves locally and releases capture before transferring the encoded recording once", async () => { + const stopTrack = vi.fn(); + getUserMedia.mockResolvedValue({ + getVideoTracks: () => [], + getTracks: () => [{ stop: stopTrack }], + }); + await startBrowserRecording("transfer-tab"); + let finishUpload!: () => void; + const uploaded = new Promise((resolve) => { + finishUpload = resolve; + }); + const transfer = vi.fn(async (artifact, blob: Blob) => { + expect(save).toHaveBeenCalledOnce(); + expect(stopTrack).toHaveBeenCalled(); + expect(artifact.path).toBe("/tmp/recording-test.webm"); + expect(blob.type).toBe("video/webm;codecs=vp9"); + await uploaded; + return "uploaded-recording"; + }); + const localStop = stopBrowserRecording("transfer-tab"); + const firstStop = stopBrowserRecordingForUpload("transfer-tab", transfer); + const secondStop = stopBrowserRecordingForUpload("transfer-tab", transfer); + finishUpload(); + expect(await firstStop).toEqual(await secondStop); + expect((await firstStop)?.uploadedAttachmentId).toBe("uploaded-recording"); + expect((await localStop)?.path).toBe("/tmp/recording-test.webm"); + expect(transfer).toHaveBeenCalledOnce(); + }); + + it("keeps the saved desktop file and releases the recording when transfer fails", async () => { + await startBrowserRecording("failed-transfer-tab"); + await expect( + stopBrowserRecordingForUpload("failed-transfer-tab", async () => { + throw new Error("Connection interrupted"); + }), + ).rejects.toThrow("Connection interrupted"); + expect(save).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds().has("failed-transfer-tab")).toBe(false); + await startBrowserRecording("failed-transfer-tab"); + await stopBrowserRecording("failed-transfer-tab"); + }); + it("paints and holds a hidden browser surface for the recording lifetime", async () => { startScreencast.mockImplementationOnce(async (tabId: string) => { expect(animationFrameCount).toBe(2); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index e362a358e7..6b70d5a53a 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -103,6 +103,8 @@ interface ActiveRecording { releaseSurfaceActivity: (() => void) | null; stream: MediaStream | null; recorder: MediaRecorder | null; + savedBlob?: Blob; + uploadPromise?: Promise; lifecycle: BrowserRecordingLifecycle; } @@ -581,6 +583,7 @@ const finalizeBrowserRecording = async ( mimeType, new Uint8Array(await blob.arrayBuffer()), ); + recording.savedBlob = blob; result = { _tag: "Success", artifact }; } catch (cause) { throw new BrowserRecordingOperationError({ @@ -686,3 +689,16 @@ export function stopBrowserRecording( recording.lifecycle = { phase: "stopping", stopPromise }; return stopPromise; } + +/** Joins local stops and shares one upload among concurrent automation requests. */ +export async function stopBrowserRecordingForUpload( + tabId: string, + upload: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise, +): Promise<(DesktopPreviewRecordingArtifact & { uploadedAttachmentId: string }) | null> { + const recording = activeRecordings.get(tabId); + if (!recording) return null; + const artifact = await stopBrowserRecording(tabId); + if (!artifact || !recording.savedBlob) return null; + recording.uploadPromise ??= upload(artifact, recording.savedBlob); + return { ...artifact, uploadedAttachmentId: await recording.uploadPromise }; +} diff --git a/apps/web/src/browser/browserRecordingUpload.ts b/apps/web/src/browser/browserRecordingUpload.ts new file mode 100644 index 0000000000..3993528983 --- /dev/null +++ b/apps/web/src/browser/browserRecordingUpload.ts @@ -0,0 +1,84 @@ +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingTooLargeError, + PreviewAutomationRecordingDeadlineExpiredError, + type DesktopPreviewRecordingArtifact, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + deletePendingAttachmentUpload, + runAttachmentUploadCycle, +} from "@t3tools/client-runtime/state/attachments"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { attachmentEnvironment } from "~/state/attachments"; +import { readPreparedConnection } from "~/state/session"; + +/** Sends the finished encoded file once; capture frames never cross the environment connection. */ +export async function uploadBrowserRecording( + { environmentId, threadId }: ScopedThreadRef, + artifact: DesktopPreviewRecordingArtifact, + blob: Blob, + deadlineMs: number, +): Promise { + if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + throw new PreviewAutomationRecordingTooLargeError({ threadId }); + } + const result = await runAttachmentUploadCycle({ + registry: appAtomRegistry, + createUploadUrl: attachmentEnvironment.createUploadUrl, + remove: attachmentEnvironment.remove, + environmentId, + upload: { + type: "file", + name: artifact.path.split(/[\\/]/).at(-1) ?? artifact.id, + mimeType: artifact.mimeType, + sizeBytes: blob.size, + }, + resolveUploadUrl: (relativeUrl) => { + const connection = readPreparedConnection(environmentId); + return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null; + }, + transport: (url) => { + const controller = new AbortController(); + // Encoding, saving and minting consume the same request budget. Leave time to reply. + const remainingMs = deadlineMs - Date.now() - 1_000; + return { + abort: () => controller.abort(), + done: + remainingMs <= 0 + ? Promise.reject(new Error("Recording transfer deadline expired.")) + : fetch(url, { + method: "POST", + headers: { "Content-Type": artifact.mimeType }, + body: blob, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(remainingMs)]), + }).then((response) => { + if (!response.ok) + throw new Error(`Recording upload rejected (${response.status}).`); + }), + }; + }, + }); + if (result.status !== "uploaded") { + if (result.attachmentId) { + deletePendingAttachmentUpload({ + registry: appAtomRegistry, + remove: attachmentEnvironment.remove, + environmentId, + attachmentId: result.attachmentId, + }); + } + const cause = result.status === "failed" ? result.error : undefined; + if (Date.now() >= deadlineMs - 1_000) { + throw new PreviewAutomationRecordingDeadlineExpiredError({ threadId, cause }); + } + throw new PreviewAutomationRecordingTransferError({ + threadId, + cause, + }); + } + return result.attachmentId; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 08b3164090..149fe025bd 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -35,8 +35,10 @@ import { readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, + stopBrowserRecordingForUpload, } from "~/browser/browserRecording"; import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; +import { uploadBrowserRecording } from "~/browser/browserRecordingUpload"; import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, @@ -710,7 +712,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const stopRuntimeTabId = activeRecordings.find((recording) => recording.serverTabId === stopTabId) ?.runtimeTabId ?? null; - const artifact = stopRuntimeTabId ? await stopBrowserRecording(stopRuntimeTabId) : null; + const transferToEnvironment = + typeof request.input === "object" && + request.input !== null && + "transferToEnvironment" in request.input && + request.input.transferToEnvironment === true; + const artifact = stopRuntimeTabId + ? transferToEnvironment + ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) => + uploadBrowserRecording(threadRef, saved, blob, hostDeadlineMs), + ) + : await stopBrowserRecording(stopRuntimeTabId) + : null; if (!artifact || !stopTabId) { return raisePreviewAutomationHostError( new PreviewAutomationRecordingNotActiveError({ @@ -721,7 +734,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }), ); } - return { ...artifact, tabId: stopTabId }; + return { + ...artifact, + tabId: stopTabId, + }; } } } catch (cause) { diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index 97a099ec72..e198befa3a 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -2,6 +2,10 @@ import { EnvironmentId, type PreviewAutomationHost, PreviewAutomationOperation, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingDesktopUpdateRequiredError, + PreviewAutomationRecordingTooLargeError, + PreviewAutomationRecordingDeadlineExpiredError, type PreviewAutomationRequest, type PreviewAutomationResponse, PreviewTabId, @@ -206,6 +210,10 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass
()(
+  "PreviewAutomationRecordingTransferError",
+  {
+    threadId: ThreadId,
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return "Preview recording could not be saved to the agent environment. The saved copy remains on the desktop.";
+  }
+}
+
+export class PreviewAutomationRecordingDesktopUpdateRequiredError extends Schema.TaggedErrorClass()(
+  "PreviewAutomationRecordingDesktopUpdateRequiredError",
+  { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
+) {
+  override get message(): string {
+    return "Update the desktop app to transfer recordings. The recording remains on the desktop.";
+  }
+}
+
+export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedErrorClass()(
+  "PreviewAutomationRecordingTooLargeError",
+  { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
+) {
+  override get message(): string {
+    return "The recording exceeds 50 MiB. The saved copy remains on the desktop.";
+  }
+}
+
+export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.TaggedErrorClass()(
+  "PreviewAutomationRecordingDeadlineExpiredError",
+  { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
+) {
+  override get message(): string {
+    return "The recording transfer deadline expired. The saved copy remains on the desktop.";
+  }
+}
+
 export const PreviewAutomationError = Schema.Union([
+  PreviewAutomationRecordingTransferError,
+  PreviewAutomationRecordingDesktopUpdateRequiredError,
+  PreviewAutomationRecordingTooLargeError,
+  PreviewAutomationRecordingDeadlineExpiredError,
   PreviewAutomationUnavailableError,
   PreviewAutomationNoAvailableHostError,
   PreviewAutomationUnsupportedClientError,

From 14602a50321bfb20cd0b8cbd1c4c8c2d1cb213e3 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Mon, 7 Sep 2026 21:56:52 -0700
Subject: [PATCH 03/14] fix(desktop): enable context menus in the browser
 (#10670)

Pylon adaptation: the test's fake renderer keeps Pylon's
pylon-code-dev:// URL.

(cherry picked from commit b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9)
---
 .../desktop/src/electron/ElectronMenu.test.ts |   3 +
 apps/desktop/src/electron/ElectronMenu.ts     |   2 +
 apps/desktop/src/window/DesktopWindow.test.ts | 131 +++++++++++++++++-
 apps/desktop/src/window/DesktopWindow.ts      | 103 +++++++++-----
 4 files changed, 200 insertions(+), 39 deletions(-)

diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts
index 756274a614..2da7d66895 100644
--- a/apps/desktop/src/electron/ElectronMenu.test.ts
+++ b/apps/desktop/src/electron/ElectronMenu.test.ts
@@ -154,9 +154,11 @@ describe("ElectronMenu", () => {
       buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock }));
 
       const electronMenu = yield* ElectronMenu.ElectronMenu;
+      const frame = { routingId: 7 } as Electron.WebFrameMain;
       const popup = electronMenu.popupTemplate({
         window: {} as Electron.BrowserWindow,
         template: [{ label: "Copy" }],
+        frame,
       });
 
       assert.equal(buildFromTemplateMock.mock.calls.length, 0);
@@ -166,6 +168,7 @@ describe("ElectronMenu", () => {
 
       assert.equal(buildFromTemplateMock.mock.calls.length, 1);
       assert.equal(popupMock.mock.calls.length, 1);
+      assert.strictEqual(popupMock.mock.calls[0]?.[0].frame, frame);
     }).pipe(Effect.provide(TestLayer)),
   );
 
diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts
index b241619cd2..702a78cc83 100644
--- a/apps/desktop/src/electron/ElectronMenu.ts
+++ b/apps/desktop/src/electron/ElectronMenu.ts
@@ -22,6 +22,7 @@ export interface ElectronMenuContextInput {
 export interface ElectronMenuTemplateInput {
   readonly window: Electron.BrowserWindow;
   readonly template: readonly Electron.MenuItemConstructorOptions[];
+  readonly frame?: Electron.WebFrameMain;
 }
 
 const ElectronMenuOperation = Schema.Literals([
@@ -207,6 +208,7 @@ export const make = Effect.gen(function* () {
             try: () =>
               Electron.Menu.buildFromTemplate([...input.template]).popup({
                 window: input.window,
+                ...(input.frame ? { frame: input.frame } : {}),
               }),
             catch: (cause) =>
               new ElectronMenuOperationError({
diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
index 51013aba2c..2a199ed950 100644
--- a/apps/desktop/src/window/DesktopWindow.test.ts
+++ b/apps/desktop/src/window/DesktopWindow.test.ts
@@ -6,12 +6,14 @@ import * as Fiber from "effect/Fiber";
 import * as Layer from "effect/Layer";
 import * as Logger from "effect/Logger";
 import * as Option from "effect/Option";
+import * as Queue from "effect/Queue";
 import * as Ref from "effect/Ref";
 import * as References from "effect/References";
 import * as Schema from "effect/Schema";
 import * as TestClock from "effect/testing/TestClock";
 
 import * as Electron from "electron";
+import * as NodeEvents from "node:events";
 import { vi } from "vite-plus/test";
 
 vi.mock("electron", async (importOriginal) => ({
@@ -66,6 +68,8 @@ function makeFakeBrowserWindow() {
   let zoomLevel = 0;
   const webContents = {
     copyImageAt: vi.fn(),
+    focus: vi.fn(),
+    isDestroyed: vi.fn(() => false),
     getURL: vi.fn(() => "pylon-code-dev://app/"),
     getZoomLevel: vi.fn(() => zoomLevel),
     setZoomLevel: vi.fn((level: number) => {
@@ -208,6 +212,8 @@ function makeTestLayer(input: {
     bounds: DesktopAppSettings.DesktopWindowBounds,
   ) => Effect.Effect;
   readonly openedExternalUrls?: unknown[];
+  readonly copiedTexts?: string[];
+  readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect;
   readonly previewZoomReapplies?: number[];
 }) {
   let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
@@ -272,7 +278,11 @@ function makeTestLayer(input: {
         desktopServerExposureLayer,
         DesktopState.layer,
         electronAppLayer,
-        electronMenuLayer,
+        Layer.succeed(ElectronMenu.ElectronMenu, {
+          setApplicationMenu: () => Effect.void,
+          showContextMenu: () => Effect.succeed(Option.none()),
+          popupTemplate: input.onPopupTemplate ?? (() => Effect.void),
+        }),
         Layer.succeed(ElectronShell.ElectronShell, {
           openExternal: (url) =>
             Effect.sync(() => {
@@ -280,7 +290,10 @@ function makeTestLayer(input: {
               return true;
             }),
           openSystemSettings: () => Effect.succeed(true),
-          copyText: () => Effect.void,
+          copyText: (text) =>
+            Effect.sync(() => {
+              input.copiedTexts?.push(text);
+            }),
         } satisfies ElectronShell.ElectronShell["Service"]),
         electronThemeLayer,
         electronWindowLayer,
@@ -399,6 +412,120 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
   });
 
 describe("DesktopWindow", () => {
+  it.effect("shows native context menus for browser guests and sign-in popups", () =>
+    Effect.gen(function* () {
+      const host = makeFakeBrowserWindow();
+      const popup = makeFakeBrowserWindow();
+      let focusedContents: unknown = host.window.webContents;
+      const makeContents = () => {
+        const contents = Object.assign(new NodeEvents.EventEmitter(), {
+          isDestroyed: vi.fn(() => false),
+          focus: vi.fn(() => {
+            focusedContents = contents;
+          }),
+          copyImageAt: vi.fn(),
+          replaceMisspelling: vi.fn(),
+        });
+        return contents;
+      };
+      const guest = makeContents();
+      const popupContents = makeContents();
+      const popupWindow = { ...popup.window, webContents: popupContents };
+      const menus = yield* Queue.unbounded<{
+        input: ElectronMenu.ElectronMenuTemplateInput;
+        focusedContents: unknown;
+      }>();
+      const copiedTexts: string[] = [];
+      const layer = makeTestLayer({
+        window: host.window,
+        createCount: yield* Ref.make(0),
+        mainWindow: yield* Ref.make>(Option.none()),
+        copiedTexts,
+        onPopupTemplate: (input) =>
+          Queue.offer(menus, { input, focusedContents }).pipe(Effect.asVoid),
+      });
+
+      yield* Effect.gen(function* () {
+        const desktopWindow = yield* DesktopWindow.DesktopWindow;
+        yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
+        const attach = host.webContentsListeners.get("did-attach-webview");
+        assert.isDefined(attach);
+        attach({}, guest);
+        attach({}, guest);
+        guest.emit("did-create-window", popupWindow);
+        guest.emit("did-create-window", popupWindow);
+
+        for (const [contents, owner] of [
+          [guest, host.window],
+          [popupContents, popupWindow],
+        ] as const) {
+          const frame = { routingId: 7 } as Electron.WebFrameMain;
+          const preventDefault = vi.fn();
+          const params = {
+            frame,
+            x: 12,
+            y: 34,
+            misspelledWord: "helo",
+            dictionarySuggestions: ["hello"],
+            linkURL: "",
+            mediaType: "none",
+            editFlags: { canCut: false, canCopy: true, canPaste: true, canSelectAll: true },
+          };
+          focusedContents = host.window.webContents;
+          contents.emit("context-menu", { preventDefault }, params);
+          const menu = yield* Queue.take(menus);
+          assert.strictEqual(menu.input.window, owner);
+          assert.strictEqual(menu.input.frame, frame);
+          assert.strictEqual(menu.focusedContents, contents);
+          assert.equal(preventDefault.mock.calls.length, 1);
+          assert.deepEqual(
+            menu.input.template.filter((item) => item.role),
+            [
+              { role: "cut", enabled: false },
+              { role: "copy", enabled: true },
+              { role: "paste", enabled: true },
+              { role: "selectAll", enabled: true },
+            ],
+          );
+          const correction = menu.input.template.find((item) => item.label === "hello");
+          assert.isDefined(correction?.click);
+          correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
+          assert.deepEqual(contents.replaceMisspelling.mock.calls, [["hello"]]);
+
+          contents.emit(
+            "context-menu",
+            { preventDefault },
+            {
+              ...params,
+              frame: null,
+              misspelledWord: "",
+              dictionarySuggestions: [],
+              mediaType: "image",
+              linkURL: "https://example.com/image.png",
+            },
+          );
+          const imageMenu = (yield* Queue.take(menus)).input;
+          assert.isUndefined(imageMenu.frame);
+          const copyImage = imageMenu.template.find((item) => item.label === "Copy Image");
+          const copyLink = imageMenu.template.find((item) => item.label === "Copy Link");
+          assert.isDefined(copyImage?.click);
+          assert.isDefined(copyLink?.click);
+          copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
+          copyLink.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
+          assert.deepEqual(contents.copyImageAt.mock.calls, [[12, 34]]);
+          assert.equal(copiedTexts.at(-1), "https://example.com/image.png");
+
+          contents.isDestroyed.mockReturnValue(true);
+          correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
+          copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
+          assert.equal(contents.replaceMisspelling.mock.calls.length, 1);
+          assert.equal(contents.copyImageAt.mock.calls.length, 1);
+          assert.equal(yield* Queue.size(menus), 0);
+        }
+      }).pipe(Effect.provide(layer));
+    }),
+  );
+
   it("leaves fullscreen before concealing a pending quit", () => {
     const fakeWindow = makeFakeBrowserWindow();
 
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index 980ba15ef0..a03eead6e5 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -492,52 +492,81 @@ export const make = Effect.gen(function* () {
       webPreferences.contextIsolation = false;
     });
 
-    window.webContents.on("context-menu", (event, params) => {
-      event.preventDefault();
+    const contextMenuContents = new WeakSet();
+    const installContextMenu = (
+      ownerWindow: Electron.BrowserWindow,
+      contents: Electron.WebContents,
+    ): void => {
+      if (contextMenuContents.has(contents)) return;
+      contextMenuContents.add(contents);
+      contents.on("context-menu", (event, params) => {
+        event.preventDefault();
+        if (contents.isDestroyed() || ownerWindow.isDestroyed()) return;
+        // Native editing roles act on the focused contents, which may still be
+        // the host renderer when the user right-clicks inside a browser guest.
+        contents.focus();
+
+        const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
+
+        if (params.misspelledWord) {
+          for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
+            menuTemplate.push({
+              label: suggestion,
+              click: () => {
+                if (!contents.isDestroyed()) contents.replaceMisspelling(suggestion);
+              },
+            });
+          }
+          if (params.dictionarySuggestions.length === 0) {
+            menuTemplate.push({ label: "No suggestions", enabled: false });
+          }
+          menuTemplate.push({ type: "separator" });
+        }
 
-      const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
+        if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
+          menuTemplate.push(
+            {
+              label: "Copy Link",
+              click: () => {
+                void runPromise(electronShell.copyText(params.linkURL));
+              },
+            },
+            { type: "separator" },
+          );
+        }
 
-      if (params.misspelledWord) {
-        for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
+        if (params.mediaType === "image") {
           menuTemplate.push({
-            label: suggestion,
-            click: () => window.webContents.replaceMisspelling(suggestion),
+            label: "Copy Image",
+            click: () => {
+              if (!contents.isDestroyed()) contents.copyImageAt(params.x, params.y);
+            },
           });
+          menuTemplate.push({ type: "separator" });
         }
-        if (params.dictionarySuggestions.length === 0) {
-          menuTemplate.push({ label: "No suggestions", enabled: false });
-        }
-        menuTemplate.push({ type: "separator" });
-      }
 
-      if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
         menuTemplate.push(
-          {
-            label: "Copy Link",
-            click: () => {
-              void runPromise(electronShell.copyText(params.linkURL));
-            },
-          },
-          { type: "separator" },
+          { role: "cut", enabled: params.editFlags.canCut },
+          { role: "copy", enabled: params.editFlags.canCopy },
+          { role: "paste", enabled: params.editFlags.canPaste },
+          { role: "selectAll", enabled: params.editFlags.canSelectAll },
         );
-      }
-
-      if (params.mediaType === "image") {
-        menuTemplate.push({
-          label: "Copy Image",
-          click: () => window.webContents.copyImageAt(params.x, params.y),
-        });
-        menuTemplate.push({ type: "separator" });
-      }
-
-      menuTemplate.push(
-        { role: "cut", enabled: params.editFlags.canCut },
-        { role: "copy", enabled: params.editFlags.canCopy },
-        { role: "paste", enabled: params.editFlags.canPaste },
-        { role: "selectAll", enabled: params.editFlags.canSelectAll },
-      );
 
-      void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));
+        void runPromise(
+          electronMenu.popupTemplate({
+            window: ownerWindow,
+            template: menuTemplate,
+            ...(params.frame ? { frame: params.frame } : {}),
+          }),
+        );
+      });
+      contents.on("did-create-window", (popup) => {
+        installContextMenu(popup, popup.webContents);
+      });
+    };
+    installContextMenu(window, window.webContents);
+    window.webContents.on("did-attach-webview", (_event, contents) => {
+      installContextMenu(window, contents);
     });
 
     window.webContents.setWindowOpenHandler(({ url }) => {

From e639839f8c6d8a7744a6bbffe13a88615139089a Mon Sep 17 00:00:00 2001
From: Theo Browne 
Date: Tue, 8 Sep 2026 05:41:10 -0700
Subject: [PATCH 04/14] fix(mcp): keep preview snapshots usable by the agent
 and let it save them (#10501)

Bound the snapshot text an agent reads below harness output limits, report
what was left out, return preview_evaluate results as { value }, cap element
names in the desktop snapshot script, and let preview_snapshot save its PNG
under the environment's browser-artifacts directory with save: true.

Pylon adaptations: PreviewScreenshotSaveError extends
Schema.TaggedErrorClass because Pylon's Effect version has no
Schema.TaggedError. The user snapshot guide describes bounded text and
saved screenshots.

(cherry picked from commit 061543e9e5b54ec0048725c37d52fef2962df173)
---
 apps/desktop/src/preview/Manager.ts           |   8 +-
 apps/server/src/config.ts                     |   3 +
 apps/server/src/mcp/McpHttpServer.test.ts     | 350 ++++++++++++++++--
 apps/server/src/mcp/McpHttpServer.ts          | 268 ++++++++++++--
 .../src/mcp/toolkits/preview/handlers.ts      |   8 +-
 apps/server/src/mcp/toolkits/preview/tools.ts |  23 +-
 docs/user/browser-snapshots.md                |  13 +
 7 files changed, 607 insertions(+), 66 deletions(-)

diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index c05203538e..d69b98912b 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -110,6 +110,12 @@ const ZOOM_EPSILON = 0.001;
 const MAX_EVALUATION_BYTES = 64_000;
 const MAX_VISIBLE_TEXT_LENGTH = 20_000;
 const MAX_INTERACTIVE_ELEMENTS = 200;
+/**
+ * A `[role]` container's innerText is its whole subtree, which turned one
+ * snapshot's element list into 60 KB of repeated page text. Names are labels,
+ * not content, so cap them where they are read.
+ */
+const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200;
 const MAX_SCREENSHOT_WIDTH = 1280;
 const RECORDING_SOURCE_SIZE_EXPRESSION =
   "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })";
@@ -3428,7 +3434,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
             return {
               tag: element.tagName.toLowerCase(),
               role: element.getAttribute("role"),
-              name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
+              name: (element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "").slice(0, ${MAX_INTERACTIVE_ELEMENT_NAME_LENGTH}),
               selector: selectorFor(element),
               x: rect.x,
               y: rect.y,
diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
index 42df3814b0..1486f6b40a 100644
--- a/apps/server/src/config.ts
+++ b/apps/server/src/config.ts
@@ -38,6 +38,8 @@ export interface ServerDerivedPaths {
   readonly providerStatusCacheDir: string;
   readonly worktreesDir: string;
   readonly attachmentsDir: string;
+  /** Screenshots the agent asks the collaborative browser to keep for the user. */
+  readonly browserArtifactsDir: string;
   readonly logsDir: string;
   readonly serverLogPath: string;
   readonly serverTracePath: string;
@@ -125,6 +127,7 @@ export const deriveServerPaths = Effect.fn(function* (
     providerStatusCacheDir,
     worktreesDir: join(baseDir, "worktrees"),
     attachmentsDir,
+    browserArtifactsDir: join(stateDir, "browser-artifacts"),
     logsDir,
     serverLogPath: join(logsDir, "server.log"),
     serverTracePath: join(logsDir, "server.trace.ndjson"),
diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts
index 5627d144cc..99dbafb64b 100644
--- a/apps/server/src/mcp/McpHttpServer.test.ts
+++ b/apps/server/src/mcp/McpHttpServer.test.ts
@@ -4,12 +4,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
 import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
 import * as Deferred from "effect/Deferred";
 import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
 import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
 import * as Stream from "effect/Stream";
 import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";
 import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";
 
+import * as ServerConfig from "../config.ts";
 import * as McpHttpServer from "./McpHttpServer.ts";
 import * as McpInvocationContext from "./McpInvocationContext.ts";
 import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
@@ -18,7 +21,7 @@ const environmentId = EnvironmentId.make("environment-mcp-test");
 const threadId = ThreadId.make("thread-mcp-test");
 const tabId = PreviewTabId.make("tab-mcp-test");
 const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
-const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
 const invocation = {
   environmentId,
   threadId,
@@ -39,9 +42,62 @@ const client = McpSchema.McpServerClient.of({
 });
 const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
   Layer.provideMerge(McpServer.McpServer.layer),
-  Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))),
+  Layer.provideMerge(PreviewAutomationBroker.layer),
+  Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),
+  Layer.provideMerge(NodeServices.layer),
 );
 
+const snapshotResult = {
+  url: "http://example.test/",
+  title: "Example",
+  loading: false,
+  visibleText: "Example",
+  interactiveElements: [],
+  accessibilityTree: {},
+  consoleEntries: [],
+  networkEntries: [],
+  actionTimeline: [],
+  screenshot: {
+    mimeType: "image/png",
+    data: Buffer.from("png").toString("base64"),
+    width: 10,
+    height: 5,
+  },
+};
+
+/** Answers every snapshot request on a fresh broker host with the given result. */
+const serveSnapshots = (clientId: string, result: unknown) =>
+  Effect.gen(function* () {
+    const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
+    const connected = yield* Deferred.make();
+    const inputs: Array = [];
+    const events = yield* broker.connect({ clientId, environmentId });
+    yield* Stream.runForEach(events, (event) => {
+      if (event.type === "connected") return Deferred.succeed(connected, undefined);
+      inputs.push(event.request.input);
+      return broker.respond({
+        clientId,
+        connectionId: event.connectionId,
+        requestId: event.request.requestId,
+        ok: true,
+        result,
+      });
+    }).pipe(Effect.forkScoped);
+    yield* Deferred.await(connected);
+    return inputs;
+  });
+
+const callSnapshot = (args: Record) =>
+  Effect.gen(function* () {
+    const server = yield* McpServer.McpServer;
+    return yield* server
+      .callTool({ name: "preview_snapshot", arguments: args })
+      .pipe(
+        Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+        Effect.provideService(McpSchema.McpServerClient, client),
+      );
+  });
+
 it("normalizes empty successful notification responses to accepted", () => {
   const notificationResponse = McpHttpServer.normalizeMcpHttpResponse(
     HttpServerResponse.text("", { status: 200, contentType: "application/json" }),
@@ -90,7 +146,9 @@ it.effect.each([{}, { includeImage: false }])(
           );
 
         expect(snapshot.isError).toBe(true);
-        expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]);
+        expect(snapshot.content).toEqual([
+          { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." },
+        ]);
         expect(snapshot.structuredContent).toEqual({
           error: {
             _tag: "PreviewAutomationExecutionError",
@@ -172,10 +230,16 @@ it.effect.each([
             Effect.provideService(McpSchema.McpServerClient, client),
           );
         const metadata = { ...page, title: `Snapshot ${call}`, screenshot };
+        const { accessibilityTree: _tree, ...boundedMetadata } = metadata;
         expect(snapshot.isError).toBe(false);
         expect(snapshot.structuredContent).toEqual(metadata);
-        expect(snapshot.content).toEqual([
-          { type: "text", text: encodeJsonText(snapshot.structuredContent) },
+        const [text, ...rest] = snapshot.content;
+        expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata);
+        expect(rest).toEqual([
+          {
+            type: "text",
+            text: "Snapshot text was bounded. Omitted: accessibilityTree (use interactiveElements locators or preview_evaluate).",
+          },
           ...(images
             ? [
                 {
@@ -198,7 +262,7 @@ it.effect.each([
           Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
           Effect.provideService(McpSchema.McpServerClient, client),
         );
-      expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "image"]);
+      expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "image"]);
       expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot });
       expect(requests).toBe(7);
     }),
@@ -219,7 +283,7 @@ it.effect("rejects non-boolean snapshot image options before selecting a browser
           Effect.provideService(McpSchema.McpServerClient, client),
         );
       expect(result.isError).toBe(true);
-      expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]);
+      expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed: AiError." }]);
       expect(result.structuredContent).toEqual({
         error: { _tag: "AiError", operation: "snapshot", failureCount: 1 },
       });
@@ -227,6 +291,222 @@ it.effect("rejects non-boolean snapshot image options before selecting a browser
   }).pipe(Effect.provide(TestLayer)),
 );
 
+it.effect("saves the snapshot PNG on request and reports its path", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const config = yield* ServerConfig.ServerConfig;
+      const fileSystem = yield* FileSystem.FileSystem;
+      const path = yield* Path.Path;
+      const inputs = yield* serveSnapshots("mcp-save-client", snapshotResult);
+
+      const snapshot = yield* callSnapshot({ save: true });
+
+      expect(snapshot.isError).toBe(false);
+      // The browser never receives the server-only `save` flag.
+      expect(inputs).toEqual([{}]);
+      const structured = snapshot.structuredContent as { readonly screenshotPath?: string };
+      const screenshotPath = structured.screenshotPath;
+      expect(typeof screenshotPath).toBe("string");
+      expect(path.dirname(screenshotPath!)).toBe(config.browserArtifactsDir);
+      expect(path.basename(screenshotPath!)).toMatch(
+        /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/,
+      );
+      expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png");
+      const text = snapshot.content.find((content) => content.type === "text");
+      expect(text?.type === "text" ? text.text : "").toContain(screenshotPath);
+
+      const unsaved = yield* callSnapshot({});
+      expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath");
+    }),
+  ).pipe(Effect.provide(TestLayer)),
+);
+
+it.effect("reports a tagged error when the screenshot cannot be saved", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const config = yield* ServerConfig.ServerConfig;
+      const fileSystem = yield* FileSystem.FileSystem;
+      // A regular file where the artifacts directory should be makes every write fail.
+      yield* fileSystem.writeFileString(config.browserArtifactsDir, "");
+      yield* serveSnapshots("mcp-save-failure-client", snapshotResult);
+
+      const snapshot = yield* callSnapshot({ save: true });
+
+      expect(snapshot.isError).toBe(true);
+      expect(snapshot.content).toEqual([
+        { type: "text", text: "Preview snapshot failed: PreviewScreenshotSaveError." },
+      ]);
+      expect(snapshot.structuredContent).toEqual({
+        error: { _tag: "PreviewScreenshotSaveError", operation: "snapshot", failureCount: 1 },
+      });
+    }),
+  ).pipe(Effect.provide(TestLayer)),
+);
+
+it.effect("keeps the snapshot text under the agent's output ceiling", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      // Mirrors the real failure: a [role] container whose innerText is the whole
+      // project list, repeated for several elements, plus a big AX tree.
+      const pageText = "/Users/theo/Code/project\nClaude, Codex · 79 threads\n".repeat(600);
+      const element = (name: string, index: number) => ({
+        tag: "div",
+        role: "presentation",
+        name,
+        selector: `div:nth-of-type(${index})`,
+        x: 0,
+        y: 0,
+        width: 10,
+        height: 10,
+      });
+      const oversized = {
+        ...snapshotResult,
+        visibleText: pageText,
+        interactiveElements: [
+          element(pageText, 1),
+          element(pageText, 2),
+          element(pageText, 3),
+          element("Continue", 4),
+        ],
+        accessibilityTree: { nodes: Array.from({ length: 2_000 }, (_, i) => ({ nodeId: `${i}` })) },
+        consoleEntries: Array.from({ length: 100 }, (_, i) => ({
+          level: "log",
+          text: `entry ${i}`,
+          timestamp: "t",
+        })),
+      };
+      yield* serveSnapshots("mcp-bounded-client", oversized);
+
+      const snapshot = yield* callSnapshot({ includeImage: false });
+
+      expect(snapshot.isError).toBe(false);
+      const [text, notice] = snapshot.content;
+      expect(text?.type).toBe("text");
+      const body = text?.type === "text" ? text.text : "";
+      expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
+        McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
+      );
+      const parsed = decodeJsonText(body) as {
+        readonly accessibilityTree?: unknown;
+        readonly visibleText: string;
+        readonly interactiveElements: ReadonlyArray<{ readonly name: string }>;
+        readonly consoleEntries: ReadonlyArray<{ readonly text: string }>;
+      };
+      expect(parsed.accessibilityTree).toBeUndefined();
+      expect(parsed.visibleText.length).toBeLessThanOrEqual(8_001);
+      expect(parsed.interactiveElements).toHaveLength(4);
+      expect(parsed.interactiveElements[0]?.name.length).toBeLessThanOrEqual(201);
+      expect(parsed.interactiveElements[3]?.name).toBe("Continue");
+      expect(parsed.consoleEntries).toHaveLength(40);
+      expect(parsed.consoleEntries[0]?.text).toBe("entry 60");
+      expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree");
+      expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries");
+      // The structured result is untouched; only the text the agent reads is bounded.
+      expect(snapshot.structuredContent).toMatchObject({
+        accessibilityTree: oversized.accessibilityTree,
+      });
+    }),
+  ).pipe(Effect.provide(TestLayer)),
+);
+
+it.effect("bounds the snapshot text even when nothing but logs and the title are large", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const oversized = {
+        ...snapshotResult,
+        title: "t".repeat(70_000),
+        interactiveElements: [],
+        consoleEntries: [{ level: "log", text: "x".repeat(70_000), timestamp: "t" }],
+      };
+      yield* serveSnapshots("mcp-bounded-logs-client", oversized);
+
+      const snapshot = yield* callSnapshot({ includeImage: false });
+
+      const [text] = snapshot.content;
+      const body = text?.type === "text" ? text.text : "";
+      expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
+        McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
+      );
+      const parsed = decodeJsonText(body) as {
+        readonly title: string;
+        readonly consoleEntries: ReadonlyArray<{ readonly text: string }>;
+      };
+      expect(parsed.title.length).toBe(2_049);
+      expect(parsed.consoleEntries[0]?.text.length).toBe(501);
+      const notice = snapshot.content[1];
+      const noticeText = notice?.type === "text" ? notice.text : "";
+      expect(noticeText).toContain("url or title after 2048 characters");
+      expect(noticeText).toContain("console entries text after 500 characters");
+    }),
+  ).pipe(Effect.provide(TestLayer)),
+);
+
+it.effect("sheds log entries before locators when every list is full", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const long = "x".repeat(2_000);
+      const oversized = {
+        ...snapshotResult,
+        interactiveElements: Array.from({ length: 20 }, (_, i) => ({
+          tag: "button",
+          role: "button",
+          name: `Button ${i}`,
+          selector: `#button-${i}`,
+          x: 0,
+          y: 0,
+          width: 10,
+          height: 10,
+        })),
+        consoleEntries: Array.from({ length: 200 }, () => ({
+          level: long,
+          text: long,
+          timestamp: long,
+          source: long,
+        })),
+        networkEntries: Array.from({ length: 200 }, () => ({
+          url: long,
+          method: long,
+          status: 200,
+          failed: false,
+          errorText: long,
+          timestamp: long,
+        })),
+        actionTimeline: Array.from({ length: 200 }, () => ({
+          id: long,
+          action: long,
+          status: "succeeded",
+          startedAt: long,
+          completedAt: long,
+          error: long,
+        })),
+      };
+      yield* serveSnapshots("mcp-full-logs-client", oversized);
+
+      const snapshot = yield* callSnapshot({ includeImage: false });
+
+      const [text, notice] = snapshot.content;
+      const body = text?.type === "text" ? text.text : "";
+      expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
+        McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
+      );
+      const parsed = decodeJsonText(body) as {
+        readonly interactiveElements: ReadonlyArray;
+        readonly consoleEntries: ReadonlyArray;
+        readonly networkEntries: ReadonlyArray;
+        readonly actionTimeline: ReadonlyArray;
+      };
+      // Locators survive; the log lists take the cut.
+      expect(parsed.interactiveElements).toHaveLength(20);
+      expect(
+        parsed.consoleEntries.length + parsed.networkEntries.length + parsed.actionTimeline.length,
+      ).toBeLessThan(120);
+      const noticeText = notice?.type === "text" ? notice.text : "";
+      expect(noticeText).toContain("40 of 40 actionTimeline");
+      expect(noticeText).not.toMatch(/\d+ of \d+ interactiveElements/);
+    }),
+  ).pipe(Effect.provide(TestLayer)),
+);
+
 it.effect("terminates HTTP MCP sessions with DELETE", () =>
   Effect.scoped(
     Effect.gen(function* () {
@@ -304,33 +584,19 @@ it.effect("registers annotated tools and preserves authenticated request context
           ok: true,
           result:
             event.request.operation === "snapshot"
-              ? {
-                  url: "http://example.test/",
-                  title: "Example",
-                  loading: false,
-                  visibleText: "Example",
-                  interactiveElements: [],
-                  accessibilityTree: {},
-                  consoleEntries: [],
-                  networkEntries: [],
-                  actionTimeline: [],
-                  screenshot: {
-                    mimeType: "image/png",
-                    data: Buffer.from("png").toString("base64"),
-                    width: 10,
-                    height: 5,
-                  },
-                }
-              : event.request.operation === "press"
-                ? undefined
-                : {
-                    available: true,
-                    visible: true,
-                    tabId,
-                    url: "http://example.test/",
-                    title: "Example",
-                    loading: false,
-                  },
+              ? snapshotResult
+              : event.request.operation === "evaluate"
+                ? ["Connect", "Continue"]
+                : event.request.operation === "press"
+                  ? undefined
+                  : {
+                      available: true,
+                      visible: true,
+                      tabId,
+                      url: "http://example.test/",
+                      title: "Example",
+                      loading: false,
+                    },
         });
       }).pipe(Effect.forkScoped);
       yield* Effect.yieldNow;
@@ -395,6 +661,22 @@ it.effect("registers annotated tools and preserves authenticated request context
         alternateTabId,
       );
 
+      // Arrays and primitives are wrapped so structuredContent stays a JSON object.
+      // Claude Code rejects the whole result otherwise.
+      const evaluateTool = server.tools.find(({ tool }) => tool.name === "preview_evaluate");
+      expect(evaluateTool?.tool.outputSchema).toMatchObject({ type: "object" });
+      const evaluated = yield* server
+        .callTool({ name: "preview_evaluate", arguments: { expression: "buttons()" } })
+        .pipe(
+          Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+          Effect.provideService(McpSchema.McpServerClient, client),
+        );
+      expect(evaluated.isError).toBe(false);
+      expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"] });
+      expect(evaluated.content).toEqual([
+        { type: "text", text: '{"value":["Connect","Continue"]}' },
+      ]);
+
       const actionRequests = [
         { name: "preview_click", arguments: { x: 10, y: 10 } },
         { name: "preview_type", arguments: { text: "Hello" } },
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index c57d029d85..c440d1f1e4 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -1,8 +1,13 @@
+import * as NodeCrypto from "node:crypto";
 import * as Cause from "effect/Cause";
+import * as Clock from "effect/Clock";
 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 Schema from "effect/Schema";
 import * as Sink from "effect/Sink";
 import * as Stream from "effect/Stream";
 import type * as Types from "effect/Types";
@@ -10,6 +15,7 @@ import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";
 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
 
 import packageJson from "../../package.json" with { type: "json" };
+import * as ServerConfig from "../config.ts";
 import * as McpInvocationContext from "./McpInvocationContext.ts";
 import * as McpSessionRegistry from "./McpSessionRegistry.ts";
 import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
@@ -96,6 +102,204 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{
   provides: McpInvocationContext.McpInvocationContext;
 }>()(makeMcpAuthMiddleware).layer;
 
+/**
+ * Claude Code drops every MCP result above 25k tokens (~100 KB of text) and
+ * hands the agent a truncation notice instead, so a snapshot that carries the
+ * full accessibility tree and 20 KB of page text loses its locators too. Keep
+ * the text under that ceiling and tell the agent what was cut.
+ */
+export const MAX_SNAPSHOT_TEXT_BYTES = 60_000;
+const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000;
+const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200;
+const MAX_SNAPSHOT_LOG_ENTRIES = 40;
+const MAX_SNAPSHOT_LOG_TEXT_CHARS = 500;
+const MAX_SNAPSHOT_IDENTIFIER_CHARS = 2_048;
+
+const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+const utf8Length = (text: string) => Buffer.byteLength(text, "utf8");
+const cutText = (text: string, max: number) =>
+  text.length > max ? `${text.slice(0, max)}…` : text;
+
+/** Shortens every string field of a log entry; other fields pass through. */
+const cutEntryStrings = (entry: A): A =>
+  typeof entry === "object" && entry !== null
+    ? (Object.fromEntries(
+        Object.entries(entry).map(([key, value]) => [
+          key,
+          typeof value === "string" ? cutText(value, MAX_SNAPSHOT_LOG_TEXT_CHARS) : value,
+        ]),
+      ) as A)
+    : entry;
+
+const hasLongString = (entry: unknown, max: number) =>
+  typeof entry === "object" &&
+  entry !== null &&
+  Object.values(entry).some((value) => typeof value === "string" && value.length > max);
+
+type SnapshotMetadata = {
+  readonly url: string;
+  readonly title: string;
+  readonly visibleText: string;
+  readonly interactiveElements: ReadonlyArray<{
+    readonly name: string;
+    readonly [key: string]: unknown;
+  }>;
+  readonly consoleEntries: ReadonlyArray;
+  readonly networkEntries: ReadonlyArray;
+  readonly actionTimeline: ReadonlyArray;
+  readonly [key: string]: unknown;
+};
+
+/**
+ * Drops the accessibility tree, shortens page text, element names, identifiers,
+ * and log strings, keeps only the newest log entries, and finally sheds
+ * interactive elements until the JSON fits. Returns the text plus notes on
+ * what is missing so the agent can reach for preview_evaluate.
+ */
+const boundSnapshotMetadata = (
+  metadata: SnapshotMetadata,
+): { readonly text: string; readonly omitted: ReadonlyArray } => {
+  const omitted: Array = [];
+  const { accessibilityTree, ...withoutTree } = metadata;
+  if (accessibilityTree !== undefined) {
+    omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)");
+  }
+  const tail = (entries: ReadonlyArray, label: string) => {
+    if (entries.length > MAX_SNAPSHOT_LOG_ENTRIES) {
+      omitted.push(`${entries.length - MAX_SNAPSHOT_LOG_ENTRIES} older ${label}`);
+    }
+    const kept = entries.slice(-MAX_SNAPSHOT_LOG_ENTRIES);
+    if (kept.some((entry) => hasLongString(entry, MAX_SNAPSHOT_LOG_TEXT_CHARS))) {
+      omitted.push(`${label} text after ${MAX_SNAPSHOT_LOG_TEXT_CHARS} characters`);
+    }
+    return kept.map(cutEntryStrings);
+  };
+  if (
+    metadata.url.length > MAX_SNAPSHOT_IDENTIFIER_CHARS ||
+    metadata.title.length > MAX_SNAPSHOT_IDENTIFIER_CHARS
+  ) {
+    omitted.push(`url or title after ${MAX_SNAPSHOT_IDENTIFIER_CHARS} characters`);
+  }
+  if (
+    metadata.interactiveElements.some(
+      (element) => element.name.length > MAX_SNAPSHOT_ELEMENT_NAME_CHARS,
+    )
+  ) {
+    omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`);
+  }
+  if (metadata.visibleText.length > MAX_SNAPSHOT_VISIBLE_TEXT_CHARS) {
+    omitted.push(
+      `visibleText after ${MAX_SNAPSHOT_VISIBLE_TEXT_CHARS} characters (use preview_evaluate for more)`,
+    );
+  }
+  const bounded = {
+    ...withoutTree,
+    url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
+    title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS),
+    visibleText: cutText(metadata.visibleText, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS),
+    interactiveElements: metadata.interactiveElements.map((element) => ({
+      ...element,
+      name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS),
+    })),
+    consoleEntries: tail(metadata.consoleEntries, "console entries"),
+    networkEntries: tail(metadata.networkEntries, "network entries"),
+    actionTimeline: tail(metadata.actionTimeline, "action timeline entries"),
+  };
+
+  // Per-field caps do not sum below the ceiling: three log arrays of 40 capped
+  // entries alone can pass 60 KB. Shed the least useful lists first, halving
+  // one list per round, until the JSON fits. With every list empty the rest
+  // is bounded by the identifier and visibleText caps, so this terminates.
+  const shedOrder = [
+    "actionTimeline",
+    "networkEntries",
+    "consoleEntries",
+    "interactiveElements",
+  ] as const;
+  const lists: Record<(typeof shedOrder)[number], ReadonlyArray> = {
+    interactiveElements: bounded.interactiveElements,
+    consoleEntries: bounded.consoleEntries,
+    networkEntries: bounded.networkEntries,
+    actionTimeline: bounded.actionTimeline,
+  };
+  const dropped: Record<(typeof shedOrder)[number], number> = {
+    interactiveElements: 0,
+    consoleEntries: 0,
+    networkEntries: 0,
+    actionTimeline: 0,
+  };
+  let text = encodeJsonText({ ...bounded, ...lists });
+  while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) {
+    // Elements carry the locators, so they go last; logs shed newest-last.
+    const key =
+      shedOrder.find(
+        (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0,
+      ) ?? (lists.interactiveElements.length > 0 ? "interactiveElements" : undefined);
+    if (key === undefined) break;
+    const keep = Math.floor(lists[key].length / 2);
+    dropped[key] += lists[key].length - keep;
+    // slice(-0) keeps everything, so spell out the empty case.
+    lists[key] =
+      keep === 0
+        ? []
+        : key === "interactiveElements"
+          ? lists[key].slice(0, keep)
+          : lists[key].slice(-keep);
+    text = encodeJsonText({ ...bounded, ...lists });
+  }
+  for (const key of shedOrder) {
+    if (dropped[key] > 0) {
+      omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`);
+    }
+  }
+  return { text, omitted };
+};
+
+export class PreviewScreenshotSaveError extends Schema.TaggedErrorClass()(
+  "PreviewScreenshotSaveError",
+  { screenshotPath: Schema.String, cause: Schema.Defect() },
+) {
+  override get message(): string {
+    return `Could not save preview screenshot to ${this.screenshotPath}.`;
+  }
+}
+
+const MAX_SCREENSHOT_SITE_SLUG_LENGTH = 40;
+
+/** Hostname reduced to a filename-safe slug, matching the desktop's own screenshot names. */
+const screenshotSiteSlug = (rawUrl: string): string => {
+  try {
+    const slug = new URL(rawUrl).hostname
+      .toLowerCase()
+      .replace(/[^a-z0-9]+/g, "-")
+      .replace(/^-+|-+$/g, "")
+      .slice(0, MAX_SCREENSHOT_SITE_SLUG_LENGTH)
+      .replace(/-+$/g, "");
+    return slug || "site";
+  } catch {
+    return "site";
+  }
+};
+
+/** Writes the snapshot PNG under the browser artifacts directory and returns its path. */
+const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
+  pageUrl: string,
+  data: Uint8Array,
+) {
+  const config = yield* ServerConfig.ServerConfig;
+  const fileSystem = yield* FileSystem.FileSystem;
+  const path = yield* Path.Path;
+  const millis = yield* Clock.currentTimeMillis;
+  // Two saves in the same millisecond must not overwrite each other.
+  const fileName = `browser-screenshot-${screenshotSiteSlug(pageUrl)}-${millis.toString(36)}-${NodeCrypto.randomUUID().slice(0, 8)}.png`;
+  const screenshotPath = path.join(config.browserArtifactsDir, fileName);
+  yield* fileSystem.makeDirectory(config.browserArtifactsDir, { recursive: true }).pipe(
+    Effect.andThen(fileSystem.writeFile(screenshotPath, data)),
+    Effect.mapError((cause) => new PreviewScreenshotSaveError({ screenshotPath, cause })),
+  );
+  return screenshotPath;
+});
+
 const previewSnapshotFailure = (cause: Cause.Cause) => {
   if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
     return Effect.failCause(cause).pipe(Effect.orDie);
@@ -118,7 +322,8 @@ const previewSnapshotFailure = (cause: Cause.Cause) => {
         failureCount: failures.length,
       },
     },
-    content: [{ type: "text", text: "Preview snapshot failed." }],
+    // Agents usually see only the text content, so name the tag there too.
+    content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }],
   });
   return Effect.logWarning("preview snapshot failed", {
     operation: "snapshot",
@@ -130,6 +335,10 @@ const previewSnapshotFailure = (cause: Cause.Cause) => {
 const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
   const server = yield* McpServer.McpServer;
   const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
+  // The MCP tool runner only supplies the client, so hand the save path its services here.
+  const saveServices = yield* Effect.context<
+    ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path
+  >();
   const built = yield* PreviewSnapshotToolkit;
   const tool = PreviewSnapshotTool;
   yield* server.addTool({
@@ -161,19 +370,21 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
           Effect.flatMap(Effect.fromOption),
           Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
           Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
-          Effect.matchCauseEffect({
-            onFailure: previewSnapshotFailure,
-            onSuccess: ({ encodedResult }) => {
-              const snapshot = encodedResult as {
+          Effect.flatMap(({ encodedResult }) =>
+            Effect.gen(function* () {
+              const snapshot = encodedResult as SnapshotMetadata & {
+                readonly url: string;
                 readonly screenshot: {
                   readonly mimeType: "image/png";
                   readonly data: string;
                   readonly width: number;
                   readonly height: number;
                 };
-                readonly [key: string]: unknown;
               };
               const { screenshot, ...page } = snapshot;
+              const png = new Uint8Array(Buffer.from(screenshot.data, "base64"));
+              const screenshotPath =
+                payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined;
               const metadata = {
                 ...page,
                 screenshot: {
@@ -181,26 +392,33 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
                   width: screenshot.width,
                   height: screenshot.height,
                 },
+                ...(screenshotPath === undefined ? {} : { screenshotPath }),
               };
-              return Effect.succeed(
-                new McpSchema.CallToolResult({
-                  isError: false,
-                  structuredContent: metadata,
-                  content: [
-                    { type: "text", text: JSON.stringify(metadata) },
-                    ...(payload?.includeImage === false
-                      ? []
-                      : [
-                          {
-                            type: "image" as const,
-                            data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
-                            mimeType: screenshot.mimeType,
-                          },
-                        ]),
-                  ],
-                }),
-              );
-            },
+              const bounded = boundSnapshotMetadata(metadata);
+              return new McpSchema.CallToolResult({
+                isError: false,
+                structuredContent: metadata,
+                content: [
+                  { type: "text", text: bounded.text },
+                  ...(bounded.omitted.length === 0
+                    ? []
+                    : [
+                        {
+                          type: "text" as const,
+                          text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`,
+                        },
+                      ]),
+                  ...(payload?.includeImage === false
+                    ? []
+                    : [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]),
+                ],
+              });
+            }),
+          ),
+          Effect.provide(saveServices),
+          Effect.matchCauseEffect({
+            onFailure: previewSnapshotFailure,
+            onSuccess: Effect.succeed,
           }),
         );
       }),
diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts
index 3cf3322cc6..d34c2d3ba3 100644
--- a/apps/server/src/mcp/toolkits/preview/handlers.ts
+++ b/apps/server/src/mcp/toolkits/preview/handlers.ts
@@ -166,8 +166,8 @@ const handlers = {
   preview_set_appearance: (input) =>
     invokeTargeted("setColorScheme", input),
   preview_snapshot: (input) => {
-    // Output selection is MCP-only; the browser still produces a complete snapshot.
-    const { includeImage: _includeImage, ...operationInput } = input ?? {};
+    // Output selection and saving are MCP-only; the browser still produces a complete snapshot.
+    const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
     return invokeTargeted("snapshot", operationInput);
   },
   preview_click: (input) =>
@@ -176,7 +176,9 @@ const handlers = {
   preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as({})),
   preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as({})),
   preview_evaluate: (input) =>
-    invokeTargeted("evaluate", input).pipe(Effect.map((result) => result ?? null)),
+    invokeTargeted("evaluate", input).pipe(
+      Effect.map((result) => ({ value: result ?? null })),
+    ),
   preview_wait_for: (input) =>
     invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as({})),
   preview_recording_start: (input) =>
diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts
index 0429f441ff..28a2b96228 100644
--- a/apps/server/src/mcp/toolkits/preview/tools.ts
+++ b/apps/server/src/mcp/toolkits/preview/tools.ts
@@ -113,7 +113,7 @@ const PreviewSetAppearanceTool = safeBrowserTool(
 export const PreviewSnapshotTool = readonlyBrowserTool(
   Tool.make("preview_snapshot", {
     description:
-      "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata.",
+      "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
     parameters: Schema.Struct({
       ...PreviewAutomationTabTargetInput.fields,
       includeImage: Schema.optional(
@@ -122,6 +122,12 @@ export const PreviewSnapshotTool = readonlyBrowserTool(
             "Include the PNG image in the tool response. Defaults to true. Set false for text-only output.",
         }),
       ),
+      save: Schema.optional(
+        Schema.Boolean.annotate({
+          description:
+            "Write the screenshot PNG to disk and return its absolute path as screenshotPath. Defaults to false.",
+        }),
+      ),
     }),
     success: PreviewAutomationSnapshot,
     failure: PreviewAutomationError,
@@ -173,12 +179,23 @@ const PreviewScrollTool = safeBrowserTool(
   }).annotate(Tool.Title, "Scroll preview page"),
 );
 
+/**
+ * MCP `structuredContent` must be a JSON object, and Claude Code rejects the
+ * whole result when it is not. Wrapping keeps arrays, strings, numbers, and
+ * null valid instead of failing only for non-object expressions.
+ */
+export const PreviewEvaluateResult = Schema.Struct({
+  value: Schema.Unknown.annotate({
+    description: "The JSON-serializable value the expression produced, or null.",
+  }),
+}).annotate({ description: "The evaluated expression result." });
+
 const PreviewEvaluateTool = browserTool(
   Tool.make("preview_evaluate", {
     description:
-      "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.",
+      "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.",
     parameters: PreviewAutomationEvaluateInput,
-    success: Schema.Unknown,
+    success: PreviewEvaluateResult,
     failure: PreviewAutomationError,
     dependencies,
   }).annotate(Tool.Title, "Evaluate JavaScript in preview"),
diff --git a/docs/user/browser-snapshots.md b/docs/user/browser-snapshots.md
index ef5d36ab89..402882614e 100644
--- a/docs/user/browser-snapshots.md
+++ b/docs/user/browser-snapshots.md
@@ -10,3 +10,16 @@ it to `true` includes the screenshot again.
 
 Text-only snapshots can help avoid accumulating images in a conversation. They do not remove
 screenshots already sent to a provider. The browser still captures a complete snapshot.
+
+## Long pages
+
+Agent tools limit how much text one result can carry, so the snapshot text an agent reads stays
+compact. It leaves out the accessibility tree, shortens long page text and element names, and keeps
+the newest console, network, and action entries. When anything is shortened or left out, the
+snapshot says what, and the agent can read more of the page with `preview_evaluate`.
+
+## Saving screenshots
+
+A snapshot’s screenshot is not saved anywhere unless the agent asks. With `save: true`, Pylon writes
+the PNG to the environment the agent runs in and returns its path, so the agent can embed the
+screenshot in its reply for you to see.

From 8ae5060445dd6834d5c32af3fdbf962e8e5c28db Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 18:17:24 -0600
Subject: [PATCH 05/14] feat(desktop): refresh the macOS installer artwork
 around the Pylon mark

Adopt upstream's refreshed DMG installer layout: a 640x432 Finder window,
120px app and Applications icons at (166, 214) and (474, 214), a direct
"Drag Pylon into Applications." instruction with a curved arrow, and a
ribbon behind the icons. Nightly keeps upstream's abstract night sky
(stars and blurred clouds, no marks) with a pastel ribbon and a NIGHTLY
label; stable uses the neutral gray background and silver ribbon.

Pylon adaptations: both backgrounds embed the Pylon mark (hexagon with the
cube cutout, from assets/prod/app-icon.icon/Assets/text.svg) where upstream
embeds its wordmark, and the instruction names Pylon. DMG titles, artifact
names, signing, and app identity are unchanged.

Adopted from 991526383f95eefdd66ffd2853136c6dd34ae008 (#10632)
Adopted from 5d14c0e9686d8bedf47dd93dd3811fd639255c7e (#10819)
Adopted from 0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3 (#10820)
---
 .../resources/dmg/dmg-background-latest.svg   | 55 ++++++-----
 .../resources/dmg/dmg-background-nightly.svg  | 91 +++++++------------
 scripts/build-desktop-artifact.test.ts        | 16 ++--
 scripts/build-desktop-artifact.ts             | 19 ++--
 4 files changed, 75 insertions(+), 106 deletions(-)

diff --git a/apps/desktop/resources/dmg/dmg-background-latest.svg b/apps/desktop/resources/dmg/dmg-background-latest.svg
index 69b03f37b5..9eff051a14 100644
--- a/apps/desktop/resources/dmg/dmg-background-latest.svg
+++ b/apps/desktop/resources/dmg/dmg-background-latest.svg
@@ -1,33 +1,30 @@
-
+
+
   
-    
-      
-      
+    
+      
+      
+      
+    
+    
+      
+      
+      
     
-    
-      
-      
-    
   
-
-  
-  
-  
-  
-  
-
-  
-    PYLON
-    Desktop
-    LATEST RELEASE
-  
-
-  
-  
-  
-
-  
-    Drag Pylon to Applications
-    Open it from Applications when the copy finishes.
-  
+  
+  
+  
+  
+  
+  
+    
+      
+      
+    
+    
+  
+  Drag Pylon into Applications.
+  
+  Once copied, open it from Applications.
 
diff --git a/apps/desktop/resources/dmg/dmg-background-nightly.svg b/apps/desktop/resources/dmg/dmg-background-nightly.svg
index 223ad0492f..8d8eb16601 100644
--- a/apps/desktop/resources/dmg/dmg-background-nightly.svg
+++ b/apps/desktop/resources/dmg/dmg-background-nightly.svg
@@ -1,64 +1,37 @@
-
+
+
   
-    
-      
-      
-      
-    
-    
-      
-      
-      
+    
+      
+      
     
-    
-      
-      
-      
+    
+      
+      
+      
+    
+    
+      
+      
+      
     
-    
-      
-    
   
-
-  
-  
-  
-
-  
-    
-    
-    
-    
-    
-    
-    
-    
-    
-  
-  
-    
-    
-  
-
-  
-    
-    
-  
-  
-  
-
-  
-    PYLON
-    Desktop
-    NIGHTLY BUILD
-  
-
-  
-  
-  
-
-  
-    Drag Pylon to Applications
-    Open it from Applications when the copy finishes.
-  
+  
+  
+  
+  
+  
+  
+  
+  
+    
+      
+      
+    
+    
+  
+  NIGHTLY
+  Drag Pylon into Applications.
+  
+  Once copied, open it from Applications.
 
diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts
index f79cfa65df..e6c21c419e 100644
--- a/scripts/build-desktop-artifact.test.ts
+++ b/scripts/build-desktop-artifact.test.ts
@@ -939,12 +939,12 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
       assert.deepStrictEqual(mac.dmg, {
         title: "Pylon (Alpha) 1.2.3 Installer",
         background: "dmg/dmg-background-latest.png",
-        window: { width: 540, height: 412 },
+        window: { width: 640, height: 432 },
         contents: [
-          { x: 130, y: 220, type: "file" },
-          { x: 410, y: 220, type: "link", path: "/Applications" },
+          { x: 166, y: 214, type: "file" },
+          { x: 474, y: 214, type: "link", path: "/Applications" },
         ],
-        iconSize: 80,
+        iconSize: 120,
         iconTextSize: 12,
       });
       // Linux must register the Pylon renderer scheme so the generated .desktop
@@ -1847,8 +1847,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
               "format",
               "png",
               "-z",
-              "380",
-              "540",
+              "432",
+              "640",
               sourcePath,
               "--out",
               path.join(dmgDir, "dmg-background-nightly.png"),
@@ -1859,8 +1859,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
               "format",
               "png",
               "-z",
-              "760",
-              "1080",
+              "864",
+              "1280",
               sourcePath,
               "--out",
               path.join(dmgDir, "dmg-background-nightly@2x.png"),
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index bb61635464..c63d9e8b2b 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -2778,8 +2778,8 @@ export const stageDesktopDmgBackground = Effect.fn("stageDesktopDmgBackground")(
   }
 
   for (const output of [
-    { suffix: "", width: 540, height: 380 },
-    { suffix: "@2x", width: 1080, height: 760 },
+    { suffix: "", width: 640, height: 432 },
+    { suffix: "@2x", width: 1280, height: 864 },
   ] as const) {
     const targetPath = path.join(
       stageResourcesDir,
@@ -3113,17 +3113,16 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
       title: `${resolveDesktopProductName(version)} ${version} Installer`,
       background: `dmg/dmg-background-${updateChannel}.png`,
       window: {
-        width: 540,
-        // Finder counts its 32px title bar in the window bounds. The themed
-        // background itself is 380px tall, so add the chrome height here to
-        // keep the full canvas visible.
-        height: 412,
+        width: 640,
+        // The DMG backend derives bounds from the image, including Finder's
+        // 32px title bar. Keep the last 32px of the artwork free of content.
+        height: 432,
       },
       contents: [
-        { x: 130, y: 220, type: "file" },
-        { x: 410, y: 220, type: "link", path: "/Applications" },
+        { x: 166, y: 214, type: "file" },
+        { x: 474, y: 214, type: "link", path: "/Applications" },
       ],
-      iconSize: 80,
+      iconSize: 120,
       iconTextSize: 12,
     };
   }

From b03902b05bacf07baa90e9caaf339e58edc1e74f Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 18:18:15 -0600
Subject: [PATCH 06/14] docs(user): describe remote media fallback and
 recording transfer

Explain that desktop retries missing absolute media from remote threads
against its own environment, and that agent recordings are transferred to
the agent's environment with a 50 MiB and two-minute limit.

Documents behavior adopted from a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3 (#10619)
Documents behavior adopted from 9e37f0c291974d084a59d7b9f165a1cb056e6043 (#10572)
---
 docs/user/browser-snapshots.md | 9 +++++++++
 docs/user/composer.md          | 5 +++++
 2 files changed, 14 insertions(+)

diff --git a/docs/user/browser-snapshots.md b/docs/user/browser-snapshots.md
index 402882614e..98cfdf5964 100644
--- a/docs/user/browser-snapshots.md
+++ b/docs/user/browser-snapshots.md
@@ -23,3 +23,12 @@ snapshot says what, and the agent can read more of the page with `preview_evalua
 A snapshot’s screenshot is not saved anywhere unless the agent asks. With `save: true`, Pylon writes
 the PNG to the environment the agent runs in and returns its path, so the agent can embed the
 screenshot in its reply for you to see.
+
+## Recordings
+
+When an agent stops a browser recording, the desktop app saves the video and then transfers the
+finished file once to the environment the agent runs in. The agent receives a path it can read even
+when that environment is remote. Transfers are limited to 50 MiB, and saving and transferring share a
+two-minute limit. If a recording is too large, the transfer fails or runs out of time, or the desktop
+app is too old to transfer recordings, the agent receives an error and the saved copy stays on the
+desktop.
diff --git a/docs/user/composer.md b/docs/user/composer.md
index e2487e9c8c..87a00fde13 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -173,6 +173,11 @@ Relative paths resolve from the thread's workspace. Absolute paths and `file://`
 the environment's machine, even when you connect remotely or use your phone. Supported media
 can live outside the workspace, including in Downloads or `/tmp`.
 
+On desktop, when a thread from a remote environment embeds an absolute image or video path that
+the remote environment cannot find, Pylon tries the same path on the desktop app's own environment.
+A file on the remote environment always wins, relative paths never fall back, and those previews
+refresh when the desktop app's environment reconnects.
+
 Pylon serves the original file without adding it to attachment storage. If that file is moved
 or deleted, its preview can no longer load from the environment. A browser or device may still
 have a cached copy. Supported video formats and codecs depend on the browser or device.

From 603dcaf06a97d0dca5a7691cbc79a2aaaadbddc9 Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 18:34:10 -0600
Subject: [PATCH 07/14] docs(upstream): record the desktop fixes adoption

Adopted from a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3 (#10619)
Adopted from 9e37f0c291974d084a59d7b9f165a1cb056e6043 (#10572)
Adopted from b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9 (#10670)
Adopted from 061543e9e5b54ec0048725c37d52fef2962df173 (#10501)
Adopted from 991526383f95eefdd66ffd2853136c6dd34ae008 (#10632)
Adopted from 5d14c0e9686d8bedf47dd93dd3811fd639255c7e (#10819)
Adopted from 0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3 (#10820)
---
 .agents/upstream-review.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md
index 81437b6fb3..d1fa53e284 100644
--- a/.agents/upstream-review.md
+++ b/.agents/upstream-review.md
@@ -56,6 +56,7 @@ Historical groups are indexed in the linked archive. This file migration changes
 | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. |
 | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. |
 | Mobile new-task drafts, outbox visibility, new-task handoff and chat feed fixes / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full 19-source list in [#460](https://github.com/pylon-code/pylon/pull/460), from #10260 through #10496 | All 19 adopted, including upstream's legacy Working pill shade (`357b8d5217cd62826767b6ce6d7c47915f2777d2`). Adopted Unsent drafts with several per project, outbox icons, queueing during uploads, pending timeline rows, outbox-first new tasks with setup pill and failure card, branch threads, feedback banners, account badges, Arrange threads, and feed, pill and LegendList fixes. Preserve Pylon delivery holds (Held labels, held-creation card that carries setup edits into the pending-task editor, Manage pending sends), provider-bound drafts, Prime follow-up upload gate, rollback revert action and LegendList scroll-follow deferral. The Headers fix is ported into Effect beta.103's patch. No partial scope. Cursor unchanged. | [Mobile drafts #460](https://github.com/pylon-code/pylon/pull/460); 1,286 focused tests, four package typechecks, scoped lint, format and export checks; no local client pass. |
+| Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing absolute media from remote threads against its primary environment; agent recordings upload once to the requesting environment (older desktops get an update-required error). #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); client-runtime 47, server MCP/asset/config 99 + 5, web 62, desktop 126 and build-script 80 tests; contracts, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. |
 
 ## Deferred register
 

From 2ee25b642236849666ea7faab5e298315e881f27 Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 18:36:02 -0600
Subject: [PATCH 08/14] docs(upstream): link the desktop fixes PR in the ledger

---
 .agents/upstream-review.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md
index d1fa53e284..6d9f6b6d8f 100644
--- a/.agents/upstream-review.md
+++ b/.agents/upstream-review.md
@@ -56,7 +56,7 @@ Historical groups are indexed in the linked archive. This file migration changes
 | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. |
 | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. |
 | Mobile new-task drafts, outbox visibility, new-task handoff and chat feed fixes / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full 19-source list in [#460](https://github.com/pylon-code/pylon/pull/460), from #10260 through #10496 | All 19 adopted, including upstream's legacy Working pill shade (`357b8d5217cd62826767b6ce6d7c47915f2777d2`). Adopted Unsent drafts with several per project, outbox icons, queueing during uploads, pending timeline rows, outbox-first new tasks with setup pill and failure card, branch threads, feedback banners, account badges, Arrange threads, and feed, pill and LegendList fixes. Preserve Pylon delivery holds (Held labels, held-creation card that carries setup edits into the pending-task editor, Manage pending sends), provider-bound drafts, Prime follow-up upload gate, rollback revert action and LegendList scroll-follow deferral. The Headers fix is ported into Effect beta.103's patch. No partial scope. Cursor unchanged. | [Mobile drafts #460](https://github.com/pylon-code/pylon/pull/460); 1,286 focused tests, four package typechecks, scoped lint, format and export checks; no local client pass. |
-| Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing absolute media from remote threads against its primary environment; agent recordings upload once to the requesting environment (older desktops get an update-required error). #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); client-runtime 47, server MCP/asset/config 99 + 5, web 62, desktop 126 and build-script 80 tests; contracts, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. |
+| Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing absolute media from remote threads against its primary environment; agent recordings upload once to the requesting environment (older desktops get an update-required error). #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); client-runtime 47, server MCP/asset/config 99 + 5, web 62, desktop 126 and build-script 80 tests; contracts, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. |
 
 ## Deferred register
 

From e1aa733ba80a6b93eaf82c05d544d67739f2bea5 Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 19:38:52 -0600
Subject: [PATCH 09/14] fix(preview): keep agent recordings through rollbacks
 and in place for the desktop's own environment

A transferred recording was a thread attachment that no message referenced,
so a rollback deleted it even when a kept turn embedded it. Transferred
recordings and saved snapshot screenshots now live in the thread's own
browser-artifacts folder, which rollbacks never sweep and thread deletion
removes.

The desktop no longer uploads a recording to its own primary environment. It
returns the saved path, as before recording transfer, and tells the server so
instead of looking like an outdated desktop. Other environments still get one
transfer.

preview_snapshot is no longer annotated read-only or idempotent, because
save: true writes a new file on each call.
---
 apps/server/src/attachmentStore.ts            |  13 ++
 apps/server/src/mcp/McpHttpServer.test.ts     |   9 +-
 apps/server/src/mcp/McpHttpServer.ts          |  17 +-
 .../src/mcp/toolkits/preview/handlers.test.ts |  31 ++++
 .../src/mcp/toolkits/preview/handlers.ts      |  27 ++-
 apps/server/src/mcp/toolkits/preview/tools.ts |   5 +-
 .../Layers/ProjectionPipeline.test.ts         | 164 ++++++++++++++++++
 .../Layers/ProjectionPipeline.ts              |   8 +-
 .../src/browser/browserRecordingScope.test.ts |  44 ++++-
 apps/web/src/browser/browserRecordingScope.ts |  13 ++
 .../preview/PreviewAutomationHosts.tsx        |  16 +-
 11 files changed, 331 insertions(+), 16 deletions(-)

diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts
index 261b094645..eb412ebfeb 100644
--- a/apps/server/src/attachmentStore.ts
+++ b/apps/server/src/attachmentStore.ts
@@ -40,6 +40,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {
   return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
 }
 
+/**
+ * Holds the screenshots an agent saves and the recordings it receives for one thread.
+ * No message references these files, so reverts leave the directory alone and only
+ * deleting the thread removes it.
+ */
+export function resolveThreadBrowserArtifactsDir(input: {
+  readonly browserArtifactsDir: string;
+  readonly threadId: string;
+}): string | null {
+  const threadSegment = toSafeThreadAttachmentSegment(input.threadId);
+  return threadSegment ? NodePath.join(input.browserArtifactsDir, threadSegment) : null;
+}
+
 export function attachmentFileExtension(fileName: string): string {
   const extension = NodePath.extname(fileName).toLowerCase();
   // ".part" is reserved for in-flight uploads; a stored "archive.part" would
diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts
index 99dbafb64b..a3b31d66df 100644
--- a/apps/server/src/mcp/McpHttpServer.test.ts
+++ b/apps/server/src/mcp/McpHttpServer.test.ts
@@ -307,7 +307,8 @@ it.effect("saves the snapshot PNG on request and reports its path", () =>
       const structured = snapshot.structuredContent as { readonly screenshotPath?: string };
       const screenshotPath = structured.screenshotPath;
       expect(typeof screenshotPath).toBe("string");
-      expect(path.dirname(screenshotPath!)).toBe(config.browserArtifactsDir);
+      // Saved with the thread, so deleting the thread removes it.
+      expect(path.dirname(screenshotPath!)).toBe(path.join(config.browserArtifactsDir, threadId));
       expect(path.basename(screenshotPath!)).toMatch(
         /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/,
       );
@@ -606,9 +607,11 @@ it.effect("registers annotated tools and preserves authenticated request context
       expect(statusTool?.tool.annotations?.idempotentHint).toBe(true);
       expect(statusTool?.tool.annotations?.destructiveHint).toBe(false);
 
+      // `save: true` writes a new file on every call.
       const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot");
-      expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true);
-      expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true);
+      expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(false);
+      expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(false);
+      expect(snapshotTool?.tool.annotations?.destructiveHint).toBe(false);
       expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true);
 
       const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click");
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index c440d1f1e4..58be55bd75 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -15,6 +15,7 @@ import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";
 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
 
 import packageJson from "../../package.json" with { type: "json" };
+import { resolveThreadBrowserArtifactsDir } from "../attachmentStore.ts";
 import * as ServerConfig from "../config.ts";
 import * as McpInvocationContext from "./McpInvocationContext.ts";
 import * as McpSessionRegistry from "./McpSessionRegistry.ts";
@@ -281,8 +282,9 @@ const screenshotSiteSlug = (rawUrl: string): string => {
   }
 };
 
-/** Writes the snapshot PNG under the browser artifacts directory and returns its path. */
+/** Writes the snapshot PNG under the thread's browser artifacts, removed with the thread. */
 const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
+  threadId: string,
   pageUrl: string,
   data: Uint8Array,
 ) {
@@ -290,10 +292,15 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
   const fileSystem = yield* FileSystem.FileSystem;
   const path = yield* Path.Path;
   const millis = yield* Clock.currentTimeMillis;
+  const directory =
+    resolveThreadBrowserArtifactsDir({
+      browserArtifactsDir: config.browserArtifactsDir,
+      threadId,
+    }) ?? config.browserArtifactsDir;
   // Two saves in the same millisecond must not overwrite each other.
   const fileName = `browser-screenshot-${screenshotSiteSlug(pageUrl)}-${millis.toString(36)}-${NodeCrypto.randomUUID().slice(0, 8)}.png`;
-  const screenshotPath = path.join(config.browserArtifactsDir, fileName);
-  yield* fileSystem.makeDirectory(config.browserArtifactsDir, { recursive: true }).pipe(
+  const screenshotPath = path.join(directory, fileName);
+  yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(
     Effect.andThen(fileSystem.writeFile(screenshotPath, data)),
     Effect.mapError((cause) => new PreviewScreenshotSaveError({ screenshotPath, cause })),
   );
@@ -384,7 +391,9 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
               const { screenshot, ...page } = snapshot;
               const png = new Uint8Array(Buffer.from(screenshot.data, "base64"));
               const screenshotPath =
-                payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined;
+                payload?.save === true
+                  ? yield* saveScreenshot(invocation.threadId, snapshot.url, png)
+                  : undefined;
               const metadata = {
                 ...page,
                 screenshot: {
diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts
index a2a88e14fc..b76d6731c6 100644
--- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts
+++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts
@@ -67,6 +67,8 @@ describe("claimPreviewRecording", () => {
       expect(yield* claim).toEqual(first);
       expect(yield* fileSystem.readFileString(first.path)).toBe("video!");
       expect(yield* fileSystem.exists(pendingPath)).toBe(false);
+      // Kept with the thread's browser artifacts, where revert cleanup never looks.
+      expect(path.dirname(first.path)).toBe(path.join(config.browserArtifactsDir, "thread-1"));
       const wrongThread = yield* claimPreviewRecording(ThreadId.make("thread-2"), response).pipe(
         Effect.result,
       );
@@ -130,6 +132,35 @@ describe("claimPreviewRecording", () => {
       ),
   );
 
+  it.effect("returns the saved path when the desktop runs beside this environment", () =>
+    Effect.gen(function* () {
+      const config = yield* ServerConfig.ServerConfig;
+      const fileSystem = yield* FileSystem.FileSystem;
+      const recording = {
+        id: "desktop-recording",
+        tabId: "tab-1",
+        path: "/desktop/userdata/browser-artifacts/recording.webm",
+        mimeType: "video/webm",
+        sizeBytes: 60 * 1024 * 1024,
+        createdAt: "2026-09-07T00:00:00.000Z",
+      };
+      const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), {
+        ...recording,
+        savedInEnvironment: true,
+      });
+      // No size limit and no second copy: nothing crossed a connection.
+      expect(result).toEqual(recording);
+      expect(yield* fileSystem.readDirectory(config.attachmentsDir)).toEqual([]);
+      expect(yield* fileSystem.exists(config.browserArtifactsDir)).toBe(false);
+    }).pipe(
+      Effect.provide(
+        ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe(
+          Layer.provideMerge(NodeServices.layer),
+        ),
+      ),
+    ),
+  );
+
   it.effect("reports an older desktop without returning its inaccessible path", () =>
     Effect.gen(function* () {
       const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), {
diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts
index d34c2d3ba3..3c1e307f69 100644
--- a/apps/server/src/mcp/toolkits/preview/handlers.ts
+++ b/apps/server/src/mcp/toolkits/preview/handlers.ts
@@ -22,6 +22,7 @@ import {
   parseAttachmentUuid,
   parseAttachmentFileExtension,
   PENDING_ATTACHMENT_THREAD_SEGMENT,
+  resolveThreadBrowserArtifactsDir,
   toSafeThreadAttachmentSegment,
 } from "../../../attachmentStore.ts";
 import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts";
@@ -85,6 +86,8 @@ const invokeTargeted = (
 const UploadedRecordingArtifact = Schema.Struct({
   ...PreviewAutomationRecordingArtifact.fields,
   uploadedAttachmentId: Schema.optional(Schema.String),
+  /** The desktop runs beside this environment, so its saved path is readable here. */
+  savedInEnvironment: Schema.optional(Schema.Boolean),
 });
 const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact);
 
@@ -101,15 +104,31 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
         }),
     ),
   );
+  const {
+    uploadedAttachmentId: _uploadedAttachmentId,
+    savedInEnvironment,
+    ...recording
+  } = artifact;
   if (!artifact.uploadedAttachmentId) {
+    if (savedInEnvironment === true) return recording;
     return yield* new PreviewAutomationRecordingDesktopUpdateRequiredError({ threadId });
   }
   const config = yield* ServerConfig.ServerConfig;
   const uuid = parseAttachmentUuid(artifact.uploadedAttachmentId);
   const extension = parseAttachmentFileExtension(artifact.uploadedAttachmentId);
   const threadSegment = toSafeThreadAttachmentSegment(threadId);
+  const threadArtifactsDir = resolveThreadBrowserArtifactsDir({
+    browserArtifactsDir: config.browserArtifactsDir,
+    threadId,
+  });
   const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${uuid}-${extension}`;
-  if (!uuid || !extension || !threadSegment || artifact.uploadedAttachmentId !== pendingId) {
+  if (
+    !uuid ||
+    !extension ||
+    !threadSegment ||
+    !threadArtifactsDir ||
+    artifact.uploadedAttachmentId !== pendingId
+  ) {
     return yield* new PreviewAutomationRecordingTransferError({
       threadId,
     });
@@ -120,8 +139,10 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
     attachmentsDir: config.attachmentsDir,
     relativePath: `${pendingId}.${extension}`,
   });
+  // Not a thread attachment: reverts prune attachments no message references, and an
+  // agent can embed this recording in a turn that a later revert keeps.
   const finalPath = resolveAttachmentRelativePath({
-    attachmentsDir: config.attachmentsDir,
+    attachmentsDir: threadArtifactsDir,
     relativePath: `${finalId}.${extension}`,
   });
   if (!currentPath || !finalPath) {
@@ -141,6 +162,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
     );
   yield* Effect.gen(function* () {
     yield* validateFile(currentPath);
+    yield* fileSystem.makeDirectory(threadArtifactsDir, { recursive: true });
     yield* fileSystem.rename(currentPath, finalPath);
   }).pipe(
     // Another stop may already have claimed this exact upload for this thread.
@@ -151,7 +173,6 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
     ),
     Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })),
   );
-  const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact;
   return { ...recording, id: finalId, path: finalPath };
 });
 
diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts
index 28a2b96228..5b32c53141 100644
--- a/apps/server/src/mcp/toolkits/preview/tools.ts
+++ b/apps/server/src/mcp/toolkits/preview/tools.ts
@@ -110,7 +110,8 @@ const PreviewSetAppearanceTool = safeBrowserTool(
     .annotate(Tool.Idempotent, true),
 );
 
-export const PreviewSnapshotTool = readonlyBrowserTool(
+// Not read-only or idempotent: `save: true` writes a new PNG on every call.
+export const PreviewSnapshotTool = safeBrowserTool(
   Tool.make("preview_snapshot", {
     description:
       "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
@@ -226,7 +227,7 @@ const PreviewRecordingStartTool = safeBrowserTool(
 const PreviewRecordingStopTool = safeBrowserTool(
   Tool.make("preview_recording_stop", {
     description:
-      "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.",
+      "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted. Returns a path to the recording that is readable in this agent's environment. When the desktop app runs on another machine, the compressed recording (up to 50 MiB) is transferred once before the path is returned.",
     parameters: PreviewAutomationTabTargetInput,
     success: PreviewAutomationRecordingArtifact,
     failure: PreviewAutomationError,
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index 820018e3c4..98e0f1e7f7 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -23,6 +23,8 @@ import * as Schema from "effect/Schema";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
 
 import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";
+import { createPendingAttachmentId } from "../../attachmentStore.ts";
+import { claimPreviewRecording } from "../../mcp/toolkits/preview/handlers.ts";
 import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
 import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
 import {
@@ -1703,6 +1705,168 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
   },
 );
 
+it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-browser-artifacts-")))(
+  "OrchestrationProjectionPipeline",
+  (it) => {
+    it.effect("keeps claimed recordings through a revert and removes them with the thread", () =>
+      Effect.gen(function* () {
+        const fileSystem = yield* FileSystem.FileSystem;
+        const path = yield* Path.Path;
+        const projectionPipeline = yield* OrchestrationProjectionPipeline;
+        const eventStore = yield* OrchestrationEventStore;
+        const config = yield* ServerConfig;
+        const now = "2026-01-01T00:00:00.000Z";
+        const threadId = ThreadId.make("thread-browser-artifacts");
+        const revertedAttachmentId =
+          "thread-browser-artifacts-00000000-0000-4000-8000-000000000001";
+
+        const appendAndProject = (event: Parameters[0]) =>
+          eventStore
+            .append(event)
+            .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
+        const eventBase = (id: string) => ({
+          eventId: EventId.make(`evt-browser-artifacts-${id}`),
+          occurredAt: now,
+          commandId: CommandId.make(`cmd-browser-artifacts-${id}`),
+          causationEventId: null,
+          correlationId: CorrelationId.make(`cmd-browser-artifacts-${id}`),
+          metadata: {},
+        });
+        const threadEvent = (id: string) => ({
+          ...eventBase(id),
+          aggregateKind: "thread" as const,
+          aggregateId: threadId,
+        });
+
+        const uploadedAttachmentId = createPendingAttachmentId(".webm");
+        yield* fileSystem.writeFileString(
+          path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`),
+          "video!",
+        );
+        const recording = yield* claimPreviewRecording(threadId, {
+          id: "desktop-recording",
+          tabId: "tab-1",
+          path: "/desktop/recording.webm",
+          mimeType: "video/webm",
+          sizeBytes: 6,
+          createdAt: now,
+          uploadedAttachmentId,
+        });
+
+        yield* appendAndProject({
+          ...eventBase("1"),
+          type: "project.created",
+          aggregateKind: "project",
+          aggregateId: ProjectId.make("project-browser-artifacts"),
+          payload: {
+            projectId: ProjectId.make("project-browser-artifacts"),
+            title: "Project Browser Artifacts",
+            workspaceRoot: "/tmp/project-browser-artifacts",
+            defaultModelSelection: null,
+            scripts: [],
+            createdAt: now,
+            updatedAt: now,
+          },
+        });
+        yield* appendAndProject({
+          ...threadEvent("2"),
+          type: "thread.created",
+          payload: {
+            threadId,
+            projectId: ProjectId.make("project-browser-artifacts"),
+            title: "Thread Browser Artifacts",
+            modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
+            runtimeMode: "full-access",
+            branch: null,
+            worktreePath: null,
+            createdAt: now,
+            updatedAt: now,
+          },
+        });
+        for (const [turn, messageId] of [
+          [1, "message-recording"],
+          [2, "message-reverted"],
+        ] as const) {
+          yield* appendAndProject({
+            ...threadEvent(`turn-${turn}`),
+            type: "thread.turn-diff-completed",
+            payload: {
+              threadId,
+              turnId: TurnId.make(`turn-${turn}`),
+              checkpointTurnCount: turn,
+              checkpointRef: CheckpointRef.make(
+                `refs/t3/checkpoints/thread-browser-artifacts/turn/${turn}`,
+              ),
+              status: "ready",
+              files: [],
+              assistantMessageId: MessageId.make(messageId),
+              completedAt: now,
+            },
+          });
+          yield* appendAndProject({
+            ...threadEvent(`message-${turn}`),
+            type: "thread.message-sent",
+            payload: {
+              threadId,
+              messageId: MessageId.make(messageId),
+              role: "assistant",
+              // The kept turn embeds the recording; nothing lists it as an attachment.
+              text: turn === 1 ? `![Recording](${recording.path})` : "Reverted",
+              attachments:
+                turn === 1
+                  ? []
+                  : [
+                      {
+                        type: "image",
+                        id: revertedAttachmentId,
+                        name: "reverted.png",
+                        mimeType: "image/png",
+                        sizeBytes: 5,
+                      },
+                    ],
+              turnId: TurnId.make(`turn-${turn}`),
+              streaming: false,
+              createdAt: now,
+              updatedAt: now,
+            },
+          });
+        }
+        const revertedAttachmentPath = path.join(
+          config.attachmentsDir,
+          `${revertedAttachmentId}.png`,
+        );
+        yield* fileSystem.writeFileString(revertedAttachmentPath, "reverted");
+        const screenshotPath = path.join(
+          path.dirname(recording.path),
+          "browser-screenshot-example-test.png",
+        );
+        yield* fileSystem.writeFileString(screenshotPath, "png");
+
+        yield* appendAndProject({
+          ...threadEvent("revert"),
+          type: "thread.reverted",
+          payload: { threadId, turnCount: 1 },
+        });
+
+        // Revert cleanup ran, and it left the thread's browser artifacts alone.
+        assert.isFalse(yield* exists(revertedAttachmentPath));
+        assert.equal(yield* fileSystem.readFileString(recording.path), "video!");
+        assert.isTrue(yield* exists(screenshotPath));
+
+        yield* appendAndProject({
+          ...threadEvent("delete"),
+          type: "thread.deleted",
+          payload: { threadId, deletedAt: now },
+        });
+
+        assert.isFalse(yield* exists(recording.path));
+        assert.isFalse(yield* exists(path.dirname(recording.path)));
+        assert.isTrue(yield* exists(config.browserArtifactsDir));
+      }),
+    );
+  },
+);
+
 it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-delete-")))(
   "OrchestrationProjectionPipeline",
   (it) => {
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index 2f42126f8e..84529eb619 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -52,6 +52,7 @@ import {
   attachmentRelativePath,
   parseAttachmentIdFromRelativePath,
   parseThreadSegmentFromAttachmentId,
+  resolveThreadBrowserArtifactsDir,
   toSafeThreadAttachmentSegment,
 } from "../../attachmentStore.ts";
 
@@ -387,13 +388,18 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function*
     threadId: string,
   ) {
     const threadSegment = toSafeThreadAttachmentSegment(threadId);
-    if (!threadSegment) {
+    const threadBrowserArtifactsDir = resolveThreadBrowserArtifactsDir({
+      browserArtifactsDir: serverConfig.browserArtifactsDir,
+      threadId,
+    });
+    if (!threadSegment || !threadBrowserArtifactsDir) {
       yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", {
         threadId,
       });
       return;
     }
 
+    yield* fileSystem.remove(threadBrowserArtifactsDir, { recursive: true, force: true });
     const entries = yield* readAttachmentRootEntries;
     yield* Effect.forEach(
       entries,
diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts
index 304adf4f19..0d29b3054f 100644
--- a/apps/web/src/browser/browserRecordingScope.test.ts
+++ b/apps/web/src/browser/browserRecordingScope.test.ts
@@ -1,6 +1,9 @@
 import { describe, expect, it } from "vite-plus/test";
 
-import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope";
+import {
+  resolveBrowserRecordingStopTarget,
+  shouldTransferBrowserRecording,
+} from "./browserRecordingScope";
 
 describe("resolveBrowserRecordingStopTarget", () => {
   it("stops the only active recording when the implicit browser target changed", () => {
@@ -41,3 +44,42 @@ describe("resolveBrowserRecordingStopTarget", () => {
     expect(resolveBrowserRecordingStopTarget(new Set(), "tab-browsing")).toBeNull();
   });
 });
+
+describe("shouldTransferBrowserRecording", () => {
+  it("leaves the recording in place for the desktop's own environment", () => {
+    expect(
+      shouldTransferBrowserRecording({
+        transferRequested: true,
+        environmentId: "primary",
+        primaryEnvironmentId: "primary",
+      }),
+    ).toBe(false);
+  });
+
+  it("transfers to a remote, relay or tunnel environment", () => {
+    expect(
+      shouldTransferBrowserRecording({
+        transferRequested: true,
+        environmentId: "remote",
+        primaryEnvironmentId: "primary",
+      }),
+    ).toBe(true);
+    expect(
+      shouldTransferBrowserRecording({
+        transferRequested: true,
+        environmentId: "remote",
+        primaryEnvironmentId: null,
+      }),
+    ).toBe(true);
+  });
+
+  it("never transfers for a server that did not ask", () => {
+    expect(
+      shouldTransferBrowserRecording({
+        transferRequested: false,
+        environmentId: "remote",
+        primaryEnvironmentId: "primary",
+      }),
+    ).toBe(false);
+  });
+});
diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts
index ffbcc19a54..1afd6f4778 100644
--- a/apps/web/src/browser/browserRecordingScope.ts
+++ b/apps/web/src/browser/browserRecordingScope.ts
@@ -1,3 +1,16 @@
+/**
+ * Whether an agent's recording must be uploaded to its environment. The desktop's
+ * primary environment runs on the desktop's machine, so its agent reads the saved
+ * file where the desktop wrote it.
+ */
+export function shouldTransferBrowserRecording(input: {
+  readonly transferRequested: boolean;
+  readonly environmentId: string;
+  readonly primaryEnvironmentId: string | null;
+}): boolean {
+  return input.transferRequested && input.environmentId !== input.primaryEnvironmentId;
+}
+
 export function resolveBrowserRecordingStopTarget(
   activeTabIds: ReadonlySet,
   implicitTabId: string | null,
diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
index 149fe025bd..53e9666731 100644
--- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx
+++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
@@ -37,7 +37,10 @@ import {
   stopBrowserRecording,
   stopBrowserRecordingForUpload,
 } from "~/browser/browserRecording";
-import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
+import {
+  resolveBrowserRecordingStopTarget,
+  shouldTransferBrowserRecording,
+} from "~/browser/browserRecordingScope";
 import { uploadBrowserRecording } from "~/browser/browserRecordingUpload";
 import {
   acquireBrowserSurfaceActivity,
@@ -49,6 +52,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
 import { isElectron } from "~/env";
 import { useEnvironments } from "~/state/environments";
 import { previewEnvironment } from "~/state/preview";
+import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
 import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
 import { useAtomCommand } from "~/state/use-atom-command";
 
@@ -712,11 +716,16 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
             const stopRuntimeTabId =
               activeRecordings.find((recording) => recording.serverTabId === stopTabId)
                 ?.runtimeTabId ?? null;
-            const transferToEnvironment =
+            const transferRequested =
               typeof request.input === "object" &&
               request.input !== null &&
               "transferToEnvironment" in request.input &&
               request.input.transferToEnvironment === true;
+            const transferToEnvironment = shouldTransferBrowserRecording({
+              transferRequested,
+              environmentId,
+              primaryEnvironmentId: registry.get(primaryEnvironmentIdAtom),
+            });
             const artifact = stopRuntimeTabId
               ? transferToEnvironment
                 ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) =>
@@ -737,6 +746,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
             return {
               ...artifact,
               tabId: stopTabId,
+              // Without this, a server that asked for a transfer reads the missing upload
+              // as an outdated desktop.
+              ...(transferRequested && !transferToEnvironment ? { savedInEnvironment: true } : {}),
             };
           }
         }

From adb097eb6df00e1553cdbb189aace9f518fb7f4b Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 19:38:53 -0600
Subject: [PATCH 10/14] fix(assets): keep network share and device paths out of
 the desktop media fallback

A remote thread could embed a UNC path and make a Windows desktop's own
server resolve it without a click. The fallback now accepts only POSIX and
drive paths on the client, and the server issues thread-free media URLs
only for those paths.
---
 apps/server/src/server.test.ts                | 82 +++++++++++--------
 apps/server/src/ws.ts                         |  7 +-
 .../client-runtime/src/state/assets.test.ts   |  3 +
 packages/client-runtime/src/state/assets.ts   |  5 +-
 packages/shared/src/path.test.ts              | 13 +++
 packages/shared/src/path.ts                   |  9 ++
 6 files changed, 82 insertions(+), 37 deletions(-)

diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index eb7138cab5..b9f9124255 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -5599,41 +5599,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
     }).pipe(Effect.provide(NodeHttpServer.layerTest)),
   );
 
-  it.effect("serves absolute host media without a local thread and rejects relative media", () =>
-    Effect.gen(function* () {
-      yield* buildAppUnderTest();
-      const fileSystem = yield* FileSystem.FileSystem;
-      const path = yield* Path.Path;
-      const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-host-media-" });
-      const wsUrl = yield* getWsServerUrl("/ws");
-      const threadId = ThreadId.make("thread-on-another-environment");
+  it.effect(
+    "serves absolute host media without a local thread and rejects relative or UNC media",
+    () =>
+      Effect.gen(function* () {
+        yield* buildAppUnderTest();
+        const fileSystem = yield* FileSystem.FileSystem;
+        const path = yield* Path.Path;
+        const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-host-media-" });
+        const wsUrl = yield* getWsServerUrl("/ws");
+        const threadId = ThreadId.make("thread-on-another-environment");
 
-      yield* Effect.scoped(
-        withWsRpcClient(wsUrl, (client) =>
-          Effect.gen(function* () {
-            for (const [name, mimeType] of [
-              ["screenshot.png", "image/png"],
-              ["recording.mp4", "video/mp4"],
-            ] as const) {
-              const filePath = path.join(directory, name);
-              yield* fileSystem.writeFileString(filePath, "host media bytes");
-              const issued = yield* client[WS_METHODS.assetsCreateUrl]({
-                resource: { _tag: "media-file", threadId, path: filePath },
-              });
-              const response = yield* HttpClient.get(issued.relativeUrl);
-              assert.equal(response.status, 200);
-              assert.equal(response.headers["content-type"], mimeType);
-              assert.equal(yield* response.text, "host media bytes");
-
-              const error = yield* client[WS_METHODS.assetsCreateUrl]({
-                resource: { _tag: "media-file", threadId, path: name },
-              }).pipe(Effect.flip);
-              assert.equal(error._tag, "AssetWorkspaceContextNotFoundError");
-            }
-          }),
-        ),
-      );
-    }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+        yield* Effect.scoped(
+          withWsRpcClient(wsUrl, (client) =>
+            Effect.gen(function* () {
+              for (const [name, mimeType] of [
+                ["screenshot.png", "image/png"],
+                ["recording.mp4", "video/mp4"],
+              ] as const) {
+                const filePath = path.join(directory, name);
+                yield* fileSystem.writeFileString(filePath, "host media bytes");
+                const issued = yield* client[WS_METHODS.assetsCreateUrl]({
+                  resource: { _tag: "media-file", threadId, path: filePath },
+                });
+                const response = yield* HttpClient.get(issued.relativeUrl);
+                assert.equal(response.status, 200);
+                assert.equal(response.headers["content-type"], mimeType);
+                assert.equal(yield* response.text, "host media bytes");
+
+                const error = yield* client[WS_METHODS.assetsCreateUrl]({
+                  resource: { _tag: "media-file", threadId, path: name },
+                }).pipe(Effect.flip);
+                assert.equal(error._tag, "AssetWorkspaceContextNotFoundError");
+              }
+
+              // Network shares and device paths are never opened for a thread this host lacks.
+              for (const networkPath of [
+                "\\\\attacker.example\\share\\x.png",
+                "\\\\?\\C:\\Users\\demo\\clip.mp4",
+                "//attacker.example/share/x.png",
+              ]) {
+                const error = yield* client[WS_METHODS.assetsCreateUrl]({
+                  resource: { _tag: "media-file", threadId, path: networkPath },
+                }).pipe(Effect.flip);
+                assert.equal(error._tag, "AssetWorkspaceContextNotFoundError");
+              }
+            }),
+          ),
+        );
+      }).pipe(Effect.provide(NodeHttpServer.layerTest)),
   );
 
   it.effect("uploads image bytes through a signed URL issued by websocket rpc", () =>
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index acfb4f836f..74dc55d53c 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -82,6 +82,7 @@ import {
   WsRpcGroup,
 } from "@t3tools/contracts";
 import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
+import { isDriveOrPosixAbsolutePath } from "@t3tools/shared/path";
 import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
 import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
 
@@ -2664,10 +2665,14 @@ const makeWsRpcLayer = (
             Effect.gen(function* () {
               const path = yield* Path.Path;
               // An absolute media path can be linked from a thread on another environment.
+              // UNC and device paths still need a thread here, so a remote thread cannot make
+              // this machine open a network share.
               if (
                 input.resource._tag === "attachment" ||
                 input.resource._tag === "native-app-icon" ||
-                (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path))
+                (input.resource._tag === "media-file" &&
+                  path.isAbsolute(input.resource.path) &&
+                  isDriveOrPosixAbsolutePath(input.resource.path))
               ) {
                 return yield* issueAssetUrl({ resource: input.resource });
               }
diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts
index 047244bf06..026197de23 100644
--- a/packages/client-runtime/src/state/assets.test.ts
+++ b/packages/client-runtime/src/state/assets.test.ts
@@ -67,6 +67,9 @@ describe("createAssetEnvironmentAtoms", () => {
     { name: "foreign thread", path: "/tmp/clip.mp4", error: "context", fallback: true },
     { name: "remote success", path: "/tmp/clip.mp4", success: true },
     { name: "relative path", path: "clip.mp4" },
+    { name: "UNC path", path: "\\\\attacker.example\\share\\x.png" },
+    { name: "device path", path: "\\\\?\\C:\\Users\\demo\\clip.mp4" },
+    { name: "slash UNC path", path: "//attacker.example/share/x.png" },
     { name: "no primary", path: "/tmp/clip.mp4", primary: "none" },
     { name: "primary reconnect", path: "/tmp/frame.png", primary: "reconnecting" },
     { name: "same environment", path: "/tmp/clip.mp4", primary: "same" },
diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts
index adccc17852..d33bab5373 100644
--- a/packages/client-runtime/src/state/assets.ts
+++ b/packages/client-runtime/src/state/assets.ts
@@ -7,7 +7,7 @@ import {
   WS_METHODS,
 } from "@t3tools/contracts";
 import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
-import { isWindowsAbsolutePath } from "@t3tools/shared/path";
+import { isDriveOrPosixAbsolutePath } from "@t3tools/shared/path";
 import {
   getProjectFaviconResourceKey,
   isProjectFaviconFallbackUrl,
@@ -120,7 +120,8 @@ export function createAssetEnvironmentAtoms(
         error._tag === "AssetWorkspaceContextNotFoundError"
       ) ||
       resource._tag !== "media-file" ||
-      !(resource.path.startsWith("/") || isWindowsAbsolutePath(resource.path)) ||
+      // A remote thread must not make this machine open a UNC or device path.
+      !isDriveOrPosixAbsolutePath(resource.path) ||
       mediaMimeTypeFromExtension(resource.path.slice(resource.path.lastIndexOf("."))) === null
     )
       return yield* error;
diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts
index 799f225fb6..8e3bdc4653 100644
--- a/packages/shared/src/path.test.ts
+++ b/packages/shared/src/path.test.ts
@@ -1,5 +1,6 @@
 import { describe, expect, it } from "vite-plus/test";
 import {
+  isDriveOrPosixAbsolutePath,
   isExplicitRelativePath,
   isUncPath,
   isWindowsAbsolutePath,
@@ -26,6 +27,18 @@ describe("path helpers", () => {
     expect(isWindowsAbsolutePath("./repo")).toBe(false);
   });
 
+  it("accepts drive and POSIX absolute paths but not UNC or device paths", () => {
+    expect(isDriveOrPosixAbsolutePath("/tmp/clip.mp4")).toBe(true);
+    expect(isDriveOrPosixAbsolutePath("C:\\Users\\demo\\clip.mp4")).toBe(true);
+    expect(isDriveOrPosixAbsolutePath("D:/media/frame.png")).toBe(true);
+    expect(isDriveOrPosixAbsolutePath("\\\\attacker.example\\share\\x.png")).toBe(false);
+    expect(isDriveOrPosixAbsolutePath("\\\\?\\C:\\Users\\demo\\clip.mp4")).toBe(false);
+    expect(isDriveOrPosixAbsolutePath("\\\\.\\pipe\\x.png")).toBe(false);
+    expect(isDriveOrPosixAbsolutePath("//attacker.example/share/x.png")).toBe(false);
+    expect(isDriveOrPosixAbsolutePath("/\\attacker.example\\share\\x.png")).toBe(false);
+    expect(isDriveOrPosixAbsolutePath("clip.mp4")).toBe(false);
+  });
+
   it("detects explicit relative paths", () => {
     expect(isExplicitRelativePath(".")).toBe(true);
     expect(isExplicitRelativePath("..")).toBe(true);
diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts
index b6758d814a..4892b20e93 100644
--- a/packages/shared/src/path.ts
+++ b/packages/shared/src/path.ts
@@ -10,6 +10,15 @@ export function isWindowsAbsolutePath(value: string): boolean {
   return isUncPath(value) || isWindowsDrivePath(value);
 }
 
+/**
+ * A POSIX `/`-rooted path or a Windows drive path. UNC and device paths
+ * (`\\server\share`, `\\?\C:\`, `//server/share`) are excluded because a
+ * Windows host opens them over the network or through a device namespace.
+ */
+export function isDriveOrPosixAbsolutePath(value: string): boolean {
+  return isWindowsDrivePath(value) || (value.startsWith("/") && !/^\/[/\\]/.test(value));
+}
+
 export function isExplicitRelativePath(value: string): boolean {
   return (
     value === "." ||

From 522ea41c6c1ff475b82bf03f68ab95ad01f907fc Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 19:38:54 -0600
Subject: [PATCH 11/14] fix(providers): let Codex and OpenCode wait out a
 recording transfer

Stopping a recording can take up to 120 seconds, longer than older Codex
releases (60 or 120 s) and OpenCode (60 s) wait for an MCP tool. Pylon now
configures 180 seconds for its MCP server in both. Claude's defaults already
cover it, and ACP providers accept no per-server timeout.
---
 apps/server/src/mcp/McpProviderSession.ts     | 15 +++++++-
 .../src/provider/Layers/CodexAdapter.test.ts  | 37 +++++++++++++++++++
 .../src/provider/Layers/CodexAdapter.ts       |  2 +
 .../src/provider/Layers/OpenCodeAdapter.ts    |  2 +
 4 files changed, 55 insertions(+), 1 deletion(-)

diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts
index 791adb0b86..5233f7f052 100644
--- a/apps/server/src/mcp/McpProviderSession.ts
+++ b/apps/server/src/mcp/McpProviderSession.ts
@@ -1,6 +1,19 @@
-import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import {
+  PREVIEW_RECORDING_STOP_TIMEOUT_MS,
+  type EnvironmentId,
+  type ProviderInstanceId,
+  type ThreadId,
+} from "@t3tools/contracts";
 import type { ProviderRuntimeFence } from "../provider/ProviderDriver.ts";
 
+/**
+ * Per-call timeout to configure for Pylon's MCP server where a provider accepts one.
+ * It outlasts `preview_recording_stop`, the slowest tool, so a provider does not
+ * abandon a recording transfer the server is still allowed to finish. Older Codex
+ * releases default to 60 or 120 seconds and OpenCode to 60.
+ */
+export const MCP_PROVIDER_TOOL_TIMEOUT_MS = PREVIEW_RECORDING_STOP_TIMEOUT_MS + 60_000;
+
 export interface McpProviderSessionConfig {
   readonly environmentId: EnvironmentId;
   readonly threadId: ThreadId;
diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts
index 4d30ba28d4..9988a0f5d1 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.test.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts
@@ -7,7 +7,9 @@ import {
   ApprovalRequestId,
   CodexSettings,
   CommandId,
+  EnvironmentId,
   EventId,
+  PREVIEW_RECORDING_STOP_TIMEOUT_MS,
   ProviderDriverKind,
   ProviderInstanceId,
   ProviderItemId,
@@ -38,6 +40,7 @@ import * as TestClock from "effect/testing/TestClock";
 import * as CodexErrors from "effect-codex-app-server/errors";
 
 import { ServerConfig } from "../../config.ts";
+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
 import { ServerSettingsService } from "../../serverSettings.ts";
 import { ProviderAdapterValidationError } from "../Errors.ts";
 import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
@@ -302,6 +305,40 @@ validationLayer("CodexAdapterLive validation", (it) => {
       });
     }),
   );
+  it.effect("gives Pylon's MCP tools longer than the slowest tool's budget", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        validationRuntimeFactory.factory.mockClear();
+        const threadId = asThreadId("thread-mcp-timeout");
+        yield* Effect.acquireRelease(
+          Effect.sync(() =>
+            McpProviderSession.setMcpProviderSession({
+              providerSessionId: "provider-session-mcp-timeout",
+              threadId,
+              environmentId: EnvironmentId.make("environment-mcp-timeout"),
+              providerInstanceId: ProviderInstanceId.make("codex"),
+              endpoint: "http://127.0.0.1:4321/mcp",
+              authorizationHeader: "Bearer test-token",
+            }),
+          ),
+          () => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)),
+        );
+        const adapter = yield* CodexAdapter;
+
+        yield* adapter.startSession({
+          provider: ProviderDriverKind.make("codex"),
+          threadId,
+          runtimeMode: "full-access",
+        });
+
+        const appServerArgs = validationRuntimeFactory.factory.mock.calls[0]?.[0].appServerArgs;
+        NodeAssert.ok(appServerArgs?.includes("mcp_servers.t3-code.tool_timeout_sec=180.0"));
+        NodeAssert.ok(
+          McpProviderSession.MCP_PROVIDER_TOOL_TIMEOUT_MS > PREVIEW_RECORDING_STOP_TIMEOUT_MS,
+        );
+      }),
+    ),
+  );
 });
 
 const sessionRuntimeFactory = makeRuntimeFactory();
diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts
index 74cac74acd..81190e8c29 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.ts
@@ -2298,6 +2298,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
                   `mcp_servers.t3-code.url=${mcpSession.endpoint}`,
                   "-c",
                   'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
+                  "-c",
+                  `mcp_servers.t3-code.tool_timeout_sec=${(McpProviderSession.MCP_PROVIDER_TOOL_TIMEOUT_MS / 1000).toFixed(1)}`,
                 ],
               }
             : {}),
diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts
index 66d07ba8f4..2f1ae79a17 100644
--- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts
+++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts
@@ -2881,6 +2881,8 @@ export function makeOpenCodeAdapter(
                         Authorization: mcpSession.authorizationHeader,
                       },
                       oauth: false,
+                      // OpenCode also applies this to connecting and listing tools.
+                      timeout: McpProviderSession.MCP_PROVIDER_TOOL_TIMEOUT_MS,
                     },
                   }),
                 );

From eba8c05f5977f08240e4a1553f2f8f78755c26eb Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 19:38:56 -0600
Subject: [PATCH 12/14] docs(user): describe media fallback paths, local
 recordings and saved file lifetime

---
 docs/user/browser-snapshots.md | 19 +++++++++++++------
 docs/user/composer.md          | 10 ++++++----
 2 files changed, 19 insertions(+), 10 deletions(-)

diff --git a/docs/user/browser-snapshots.md b/docs/user/browser-snapshots.md
index 98cfdf5964..38c3167868 100644
--- a/docs/user/browser-snapshots.md
+++ b/docs/user/browser-snapshots.md
@@ -24,11 +24,18 @@ A snapshot’s screenshot is not saved anywhere unless the agent asks. With `sav
 the PNG to the environment the agent runs in and returns its path, so the agent can embed the
 screenshot in its reply for you to see.
 
+Because a snapshot can write a file, it is not marked read-only. Providers that ask before a tool
+changes anything, such as Codex outside Full access, ask before snapshots too.
+
 ## Recordings
 
-When an agent stops a browser recording, the desktop app saves the video and then transfers the
-finished file once to the environment the agent runs in. The agent receives a path it can read even
-when that environment is remote. Transfers are limited to 50 MiB, and saving and transferring share a
-two-minute limit. If a recording is too large, the transfer fails or runs out of time, or the desktop
-app is too old to transfer recordings, the agent receives an error and the saved copy stays on the
-desktop.
+When an agent stops a browser recording, the desktop app saves the video. If the agent runs in the
+desktop app’s own environment, it receives the path to that saved file. If the agent runs in another
+environment, the desktop app transfers the finished file there once, so the agent receives a path it
+can read. Transfers are limited to 50 MiB, and saving and transferring share a two-minute limit. If a
+recording is too large, the transfer fails or runs out of time, or the desktop app is too old to
+transfer recordings, the agent receives an error and the saved copy stays on the desktop.
+
+Saved screenshots and transferred recordings belong to their thread. Rolling back the thread keeps
+them, so an earlier reply that shows one still works. Deleting the thread removes them. A recording
+handed to the desktop app’s own environment stays where the desktop app saved it.
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 87a00fde13..c44d9e56f9 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -173,10 +173,12 @@ Relative paths resolve from the thread's workspace. Absolute paths and `file://`
 the environment's machine, even when you connect remotely or use your phone. Supported media
 can live outside the workspace, including in Downloads or `/tmp`.
 
-On desktop, when a thread from a remote environment embeds an absolute image or video path that
-the remote environment cannot find, Pylon tries the same path on the desktop app's own environment.
-A file on the remote environment always wins, relative paths never fall back, and those previews
-refresh when the desktop app's environment reconnects.
+On desktop, when a thread from a remote environment embeds an image or video that the remote
+environment cannot find, Pylon tries the same absolute path on the desktop app's own environment.
+Relative paths resolve from the remote thread's workspace first, so a missing relative image can show
+the desktop's file at that same absolute path. A file on the remote environment always wins, network
+share and device paths such as `\\server\share` never fall back, and those previews refresh when the
+desktop app's environment reconnects.
 
 Pylon serves the original file without adding it to attachment storage. If that file is moved
 or deleted, its preview can no longer load from the environment. A browser or device may still

From ab596cf74c4add108dc691bcb103f2488469da0b Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 19:38:57 -0600
Subject: [PATCH 13/14] docs(upstream): update the desktop fixes ledger row
 after review

---
 .agents/upstream-review.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md
index 6d9f6b6d8f..96ae386cff 100644
--- a/.agents/upstream-review.md
+++ b/.agents/upstream-review.md
@@ -56,7 +56,7 @@ Historical groups are indexed in the linked archive. This file migration changes
 | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. |
 | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. |
 | Mobile new-task drafts, outbox visibility, new-task handoff and chat feed fixes / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full 19-source list in [#460](https://github.com/pylon-code/pylon/pull/460), from #10260 through #10496 | All 19 adopted, including upstream's legacy Working pill shade (`357b8d5217cd62826767b6ce6d7c47915f2777d2`). Adopted Unsent drafts with several per project, outbox icons, queueing during uploads, pending timeline rows, outbox-first new tasks with setup pill and failure card, branch threads, feedback banners, account badges, Arrange threads, and feed, pill and LegendList fixes. Preserve Pylon delivery holds (Held labels, held-creation card that carries setup edits into the pending-task editor, Manage pending sends), provider-bound drafts, Prime follow-up upload gate, rollback revert action and LegendList scroll-follow deferral. The Headers fix is ported into Effect beta.103's patch. No partial scope. Cursor unchanged. | [Mobile drafts #460](https://github.com/pylon-code/pylon/pull/460); 1,286 focused tests, four package typechecks, scoped lint, format and export checks; no local client pass. |
-| Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing absolute media from remote threads against its primary environment; agent recordings upload once to the requesting environment (older desktops get an update-required error). #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); client-runtime 47, server MCP/asset/config 99 + 5, web 62, desktop 126 and build-script 80 tests; contracts, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. |
+| Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing drive or POSIX absolute media from remote threads (relative images arrive workspace-resolved) against its primary environment; UNC and device paths never fall back, client or server. Agent recordings upload once to a remote requesting environment, while the desktop's own primary environment gets the saved path with no copy (older desktops get an update-required error). Pylon adaptations: transferred recordings and `save: true` screenshots live in `browser-artifacts//` so rollbacks keep them and thread deletion removes them; `preview_snapshot` is no longer annotated read-only/idempotent (Codex asks before it outside Full access); Codex `tool_timeout_sec` and OpenCode `timeout` are 180 s for Pylon's MCP server, Claude and ACP providers keep their defaults. #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); shared 6, client-runtime 50, server MCP/asset/config/pipeline/adapter 308 + router 5, web 79, desktop 126 and build-script 80 tests; contracts, shared, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. |
 
 ## Deferred register
 

From 07b3fecf9a327862413f1366e8fc2e694c2c0fdf Mon Sep 17 00:00:00 2001
From: Trevor Walker 
Date: Thu, 10 Sep 2026 19:53:18 -0600
Subject: [PATCH 14/14] fix(mcp): keep preview_snapshot read-only for approvals

save: true writes only to the thread's own browser-artifacts store, which
deleting the thread removes, so dropping upstream's read-only and idempotent
hints only made Codex ask before every snapshot in supervised modes.
---
 .agents/upstream-review.md                    | 2 +-
 apps/server/src/mcp/McpHttpServer.test.ts     | 6 +++---
 apps/server/src/mcp/toolkits/preview/tools.ts | 5 +++--
 docs/user/browser-snapshots.md                | 3 ---
 4 files changed, 7 insertions(+), 9 deletions(-)

diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md
index 96ae386cff..a4c3b694b4 100644
--- a/.agents/upstream-review.md
+++ b/.agents/upstream-review.md
@@ -56,7 +56,7 @@ Historical groups are indexed in the linked archive. This file migration changes
 | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. |
 | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. |
 | Mobile new-task drafts, outbox visibility, new-task handoff and chat feed fixes / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full 19-source list in [#460](https://github.com/pylon-code/pylon/pull/460), from #10260 through #10496 | All 19 adopted, including upstream's legacy Working pill shade (`357b8d5217cd62826767b6ce6d7c47915f2777d2`). Adopted Unsent drafts with several per project, outbox icons, queueing during uploads, pending timeline rows, outbox-first new tasks with setup pill and failure card, branch threads, feedback banners, account badges, Arrange threads, and feed, pill and LegendList fixes. Preserve Pylon delivery holds (Held labels, held-creation card that carries setup edits into the pending-task editor, Manage pending sends), provider-bound drafts, Prime follow-up upload gate, rollback revert action and LegendList scroll-follow deferral. The Headers fix is ported into Effect beta.103's patch. No partial scope. Cursor unchanged. | [Mobile drafts #460](https://github.com/pylon-code/pylon/pull/460); 1,286 focused tests, four package typechecks, scoped lint, format and export checks; no local client pass. |
-| Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing drive or POSIX absolute media from remote threads (relative images arrive workspace-resolved) against its primary environment; UNC and device paths never fall back, client or server. Agent recordings upload once to a remote requesting environment, while the desktop's own primary environment gets the saved path with no copy (older desktops get an update-required error). Pylon adaptations: transferred recordings and `save: true` screenshots live in `browser-artifacts//` so rollbacks keep them and thread deletion removes them; `preview_snapshot` is no longer annotated read-only/idempotent (Codex asks before it outside Full access); Codex `tool_timeout_sec` and OpenCode `timeout` are 180 s for Pylon's MCP server, Claude and ACP providers keep their defaults. #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); shared 6, client-runtime 50, server MCP/asset/config/pipeline/adapter 308 + router 5, web 79, desktop 126 and build-script 80 tests; contracts, shared, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. |
+| Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing drive or POSIX absolute media from remote threads (relative images arrive workspace-resolved) against its primary environment; UNC and device paths never fall back, client or server. Agent recordings upload once to a remote requesting environment, while the desktop's own primary environment gets the saved path with no copy (older desktops get an update-required error). Pylon adaptations: transferred recordings and `save: true` screenshots live in `browser-artifacts//` so rollbacks keep them and thread deletion removes them; `preview_snapshot` keeps upstream's read-only/idempotent hints because saves stay in that per-thread store; Codex `tool_timeout_sec` and OpenCode `timeout` are 180 s for Pylon's MCP server, Claude and ACP providers keep their defaults. #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); shared 6, client-runtime 50, server MCP/asset/config/pipeline/adapter 308 + router 5, web 79, desktop 126 and build-script 80 tests; contracts, shared, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. |
 
 ## Deferred register
 
diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts
index a3b31d66df..3d57a72dbf 100644
--- a/apps/server/src/mcp/McpHttpServer.test.ts
+++ b/apps/server/src/mcp/McpHttpServer.test.ts
@@ -607,10 +607,10 @@ it.effect("registers annotated tools and preserves authenticated request context
       expect(statusTool?.tool.annotations?.idempotentHint).toBe(true);
       expect(statusTool?.tool.annotations?.destructiveHint).toBe(false);
 
-      // `save: true` writes a new file on every call.
+      // Saved screenshots stay in the thread's artifact store, so approvals treat it as read-only.
       const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot");
-      expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(false);
-      expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(false);
+      expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true);
+      expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true);
       expect(snapshotTool?.tool.annotations?.destructiveHint).toBe(false);
       expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true);
 
diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts
index 5b32c53141..c30e3f1d8a 100644
--- a/apps/server/src/mcp/toolkits/preview/tools.ts
+++ b/apps/server/src/mcp/toolkits/preview/tools.ts
@@ -110,8 +110,9 @@ const PreviewSetAppearanceTool = safeBrowserTool(
     .annotate(Tool.Idempotent, true),
 );
 
-// Not read-only or idempotent: `save: true` writes a new PNG on every call.
-export const PreviewSnapshotTool = safeBrowserTool(
+// Stays read-only for approval purposes: `save: true` writes only to the thread's own
+// browser-artifacts store, never the workspace, and deleting the thread removes it.
+export const PreviewSnapshotTool = readonlyBrowserTool(
   Tool.make("preview_snapshot", {
     description:
       "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
diff --git a/docs/user/browser-snapshots.md b/docs/user/browser-snapshots.md
index 38c3167868..80151322e6 100644
--- a/docs/user/browser-snapshots.md
+++ b/docs/user/browser-snapshots.md
@@ -24,9 +24,6 @@ A snapshot’s screenshot is not saved anywhere unless the agent asks. With `sav
 the PNG to the environment the agent runs in and returns its path, so the agent can embed the
 screenshot in its reply for you to see.
 
-Because a snapshot can write a file, it is not marked read-only. Providers that ask before a tool
-changes anything, such as Codex outside Full access, ask before snapshots too.
-
 ## Recordings
 
 When an agent stops a browser recording, the desktop app saves the video. If the agent runs in the