From 23706b7d8291a9dc38970e059b52cc960999566d Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 21:32:19 -0400 Subject: [PATCH 01/30] fix(server): device tools find a device id on any host device_open, device_screenshot and device_close defaulted hostId to this machine before looking up deviceId. An agent holding a Mac simulator's UDID got "No device ... on host local". That was 7 of the 13 device_open errors in the investigation. A deviceId without hostId now resolves across hosts. An explicit hostId stays authoritative, and its error names the host that has the id. An id on two hosts (Android AVD names can repeat) is refused, and the error lists each candidate instead of picking or closing both. device_list says it lists every device host, not just this machine. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 171 +++++++++++++++++- .../src/mcp/toolkits/device/handlers.ts | 110 ++++++++--- apps/server/src/mcp/toolkits/device/tools.ts | 2 +- 3 files changed, 258 insertions(+), 25 deletions(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index ca96fd582f328..cbedfa4bab1ed 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DeviceHostUnavailableError, @@ -234,3 +234,172 @@ it.effect("rejects unavailable agent access before booting or opening a device", ), ); }); + +describe("device targeting across hosts", () => { + const macHost = { + id: "mac-mini", + kind: "ssh" as const, + label: "Mac Mini", + platforms: [ + { platform: "ios" as const, available: true }, + { platform: "android" as const, available: true }, + ], + hubInstalled: true, + agentDeviceInstalled: true, + }; + const macIphone = { ...device, hostId: "mac-mini", id: "MAC-UDID" }; + const localPixel = { + hostId: "local", + id: "Pixel_10", + platform: "android" as const, + name: "Pixel 10", + version: "Android 16", + booted: false, + physical: false, + }; + const macPixel = { ...localPixel, hostId: "mac-mini" }; + const multiHostState = { + ...state, + hosts: [...state.hosts, macHost], + hostStatuses: { + local: { status: "ready" as const }, + "mac-mini": { status: "ready" as const }, + }, + devices: [device, macIphone, localPixel, macPixel], + }; + const session = (hostId: string, deviceId: string, platform: "ios" | "android") => ({ + threadId, + hostId, + deviceId: DeviceId.make(deviceId), + platform, + openedAt: "2026-09-08T00:00:00.000Z", + }); + + const run = ( + sessions: ReadonlyArray>, + program: (calls: { + readonly opened: Array<{ hostId: string; deviceId: string }>; + readonly closed: Array<{ hostId?: string | undefined; deviceId?: string | undefined }>; + readonly call: ( + name: string, + args: Record, + ) => Effect.Effect< + McpSchema.CallToolResult, + McpSchema.InternalError | McpSchema.InvalidParams, + McpServer.McpServer + >; + }) => Effect.Effect, + ) => { + const opened: Array<{ hostId: string; deviceId: string }> = []; + const closed: Array<{ hostId?: string | undefined; deviceId?: string | undefined }> = []; + const layerDevices = Layer.mock(DeviceService.DeviceService)({ + state: Effect.succeed(multiHostState), + list: Effect.succeed(multiHostState), + agentReadinessIfSupported: () => Effect.succeed(readiness), + open: (input) => + Effect.sync(() => { + opened.push({ hostId: input.hostId ?? "local", deviceId: input.deviceId }); + return session(input.hostId ?? "local", input.deviceId, input.platform); + }), + sessionsForThread: () => Effect.succeed(sessions), + screenshot: (target) => + Effect.succeed({ + device: + multiHostState.devices.find( + (candidate) => candidate.hostId === target.hostId && candidate.id === target.deviceId, + ) ?? device, + png, + }), + close: (input) => + Effect.sync(() => { + closed.push({ hostId: input.hostId, deviceId: input.deviceId }); + }), + agentCli: Effect.succeed("/cli"), + agentTarget: () => + Effect.succeed({ + agentDeviceId: "MAC-UDID", + args: ["--config", "/mac.json", "--session", "thread-device"], + }), + }); + const call = (name: string, args: Record) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name, arguments: args })), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])), + Effect.provideService(McpSchema.McpServerClient, client), + ); + return program({ opened, closed, call }).pipe( + Effect.scoped, + Effect.provide( + McpHttpServer.layerDeviceToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide(layerDevices), + Layer.provide(layerProjectsMock), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }), + ), + Layer.provide(NodeServices.layer), + ), + ), + ); + }; + const text = (result: McpSchema.CallToolResult) => + result.content.flatMap((entry) => (entry.type === "text" ? [entry.text] : [])).join("\n"); + + it.effect("opens a device id that exists only on another host", () => + run([], ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "MAC-UDID" }); + expect(text(result)).not.toContain("No device"); + expect(result.isError).toBe(false); + expect(opened).toEqual([{ hostId: "mac-mini", deviceId: "MAC-UDID" }]); + }), + ), + ); + + it.effect("refuses a device id that exists on two hosts and names both", () => + run([], ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "Pixel_10" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId local"); + expect(text(result)).toContain("hostId mac-mini"); + expect(opened).toEqual([]); + }), + ), + ); + + it.effect("keeps an explicit hostId and says which host has the device", () => + run([], ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "MAC-UDID", hostId: "local" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId mac-mini"); + expect(opened).toEqual([]); + }), + ), + ); + + it.effect("screenshots an open device by id on whichever host it is on", () => + run([session("mac-mini", "MAC-UDID", "ios")], ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_screenshot", { deviceId: "MAC-UDID" }); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ device: { hostId: "mac-mini" } }); + }), + ), + ); + + it.effect("refuses to close an id open on two hosts instead of closing both", () => + run( + [session("local", "Pixel_10", "android"), session("mac-mini", "Pixel_10", "android")], + ({ closed, call }) => + Effect.gen(function* () { + const result = yield* call("device_close", { deviceId: "Pixel_10" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId mac-mini"); + expect(closed).toEqual([]); + }), + ), + ); +}); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 8fdbd264b8726..db3287a69cc7d 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -1,8 +1,10 @@ import { type DeviceError, type DeviceHostId, + type DeviceHostSummary, type DeviceId, type DevicePlatform, + type DeviceSession, type DeviceSummary, DeviceToolUnavailableError, LOCAL_DEVICE_HOST_ID, @@ -72,25 +74,70 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev ), ); +const hostName = (hosts: ReadonlyArray, hostId: DeviceHostId) => { + const label = hosts.find((host) => host.id === hostId)?.label; + return label === undefined ? `hostId ${hostId}` : `'${label}' (hostId ${hostId})`; +}; + +/** + * Device ids are unique per host, not across hosts: an Android AVD name can exist on two. + * An explicit hostId decides; without one, the id has to name a single host. + */ +const selectByDeviceId = Effect.fn("handlers.selectByDeviceId")(function* < + T extends { readonly hostId: DeviceHostId }, +>(input: { + readonly deviceId: DeviceId; + readonly matches: ReadonlyArray; + readonly hostId: DeviceHostId | undefined; + readonly hosts: Effect.Effect>; + readonly missing: (hostId: DeviceHostId | undefined) => string; +}): Effect.fn.Return { + const onHost = input.matches.filter( + (match) => input.hostId === undefined || match.hostId === input.hostId, + ); + const hostIds = [...new Set(onHost.map((match) => match.hostId))]; + if (hostIds.length === 1) return onHost.at(-1)!; + const hosts = yield* input.hosts; + const names = (ids: ReadonlyArray) => + ids.map((id) => hostName(hosts, id)).join(", "); + if (hostIds.length > 1) { + return yield* new DeviceToolUnavailableError({ + reason: `Device ${input.deviceId} is on more than one host: ${names(hostIds)}. Pass hostId.`, + }); + } + const elsewhere = [...new Set(input.matches.map((match) => match.hostId))]; + return yield* new DeviceToolUnavailableError({ + reason: + input.hostId !== undefined && elsewhere.length > 0 + ? `Device ${input.deviceId} is not on ${hostName(hosts, input.hostId)}. It is on ${names(elsewhere)}; pass that hostId.` + : input.missing(input.hostId), + }); +}); + const pickDevice = Effect.fn("handlers.pickDevice")(function* ( - devices: ReadonlyArray, + state: { + readonly hosts: ReadonlyArray; + readonly devices: ReadonlyArray; + }, input: { readonly deviceId?: DeviceId | undefined; readonly platform?: DevicePlatform | undefined; readonly hostId?: DeviceHostId | undefined; }, ): Effect.fn.Return { - const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; if (input.deviceId !== undefined) { - const match = devices.find( - (device) => device.hostId === hostId && device.id === input.deviceId, - ); - if (match) return match; - return yield* new DeviceToolUnavailableError({ - reason: `No device ${input.deviceId} on host ${hostId}. Call device_list for current ids.`, + const deviceId = input.deviceId; + return yield* selectByDeviceId({ + deviceId, + matches: state.devices.filter((device) => device.id === deviceId), + hostId: input.hostId, + hosts: Effect.succeed(state.hosts), + missing: (hostId) => + `No device ${deviceId} on ${hostId === undefined ? "any host" : hostName(state.hosts, hostId)}. Call device_list for current ids.`, }); } - const candidates = devices.filter( + const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; + const candidates = state.devices.filter( (device) => device.hostId === hostId && (input.platform === undefined || device.platform === input.platform), @@ -112,6 +159,20 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( return candidates.find((device) => device.booted) ?? candidates[0]!; }); +const selectOpenSession = ( + devices: DeviceService.DeviceService["Service"], + sessions: ReadonlyArray, + deviceId: DeviceId, + hostId: DeviceHostId | undefined, +) => + selectByDeviceId({ + deviceId, + matches: sessions.filter((session) => session.deviceId === deviceId), + hostId, + hosts: devices.state.pipe(Effect.map((state) => state.hosts)), + missing: () => `Device ${deviceId} is not open in this thread. Call device_open first.`, + }); + const toolError = (error: DeviceError | DeviceToolUnavailableError) => error; const handlers = { @@ -153,7 +214,7 @@ const handlers = { "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.", }); } - const target = yield* pickDevice(state.devices, input); + const target = yield* pickDevice(state, input); // Resolve consent and agent connectivity before booting or registering a session. if (!(yield* devices.agentReadinessIfSupported(target.hostId))) { return yield* new DeviceToolUnavailableError({ @@ -221,19 +282,15 @@ const handlers = { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; const sessions = yield* devices.sessionsForThread(scope.thread.threadId); - const hostId = - input.deviceId === undefined ? input.hostId : (input.hostId ?? LOCAL_DEVICE_HOST_ID); - const target = sessions.findLast( - (session) => - (hostId === undefined || session.hostId === hostId) && - (input.deviceId === undefined || session.deviceId === input.deviceId), - ); + const target = + input.deviceId === undefined + ? sessions.findLast( + (session) => input.hostId === undefined || session.hostId === input.hostId, + ) + : yield* selectOpenSession(devices, sessions, input.deviceId, input.hostId); if (!target) { return yield* new DeviceToolUnavailableError({ - reason: - input.deviceId === undefined - ? "No device is open in this thread. Call device_open first." - : `Device ${input.deviceId} on host ${hostId} is not open in this thread. Call device_open first.`, + reason: "No device is open in this thread. Call device_open first.", }); } const shot = yield* devices.screenshot(target); @@ -251,10 +308,17 @@ const handlers = { Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; + const deviceId = input.deviceId; + const sessions = + deviceId === undefined ? [] : yield* devices.sessionsForThread(scope.thread.threadId); + const hostId = + deviceId === undefined || !sessions.some((session) => session.deviceId === deviceId) + ? input.hostId + : (yield* selectOpenSession(devices, sessions, deviceId, input.hostId)).hostId; yield* devices.close({ threadId: scope.thread.threadId, - ...(input.hostId === undefined ? {} : { hostId: input.hostId }), - ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }), + ...(hostId === undefined ? {} : { hostId }), + ...(deviceId === undefined ? {} : { deviceId }), ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }), }); return {}; diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index f02f39b32462a..1cf393ad8c2c4 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -29,7 +29,7 @@ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure] const DeviceListTool = Tool.make("device_list", { description: - "List iOS Simulators and Android Emulators here, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.", + "List iOS Simulators and Android Emulators on every device host, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.", // An empty struct serializes as `anyOf [object, array]`, which some // providers reject and then drop every tool on the server with it. parameters: Schema.Struct({ From 48a4c0d93bf05a28d93f5fcf4333f58f0759fd29 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 21:34:36 -0400 Subject: [PATCH 02/30] fix(server): device_screenshot says why it failed device_screenshot answered every failure with "Device screenshot failed.", which hid the handler's own guidance. "No device is open in this thread. Call device_open first." never reached the agent. In the investigation, an agent that saw the generic text switched to simctl over ssh. The image tool now returns typed device-tool and orchestrator failure messages, the same text the other device tools already return. Untyped failures keep the generic line. A boot failure with no host diagnostics no longer blames "the environment server", which is wrong for SSH and EAS hosts. It now says what to try next. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 26 ++++++++++++++++++++ apps/server/src/mcp/McpHttpServer.ts | 7 +++++- packages/contracts/src/device.test.ts | 26 ++++++++++++++++++++ packages/contracts/src/device.ts | 9 ++++--- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index cbedfa4bab1ed..a2fddaa532f26 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -390,6 +390,32 @@ describe("device targeting across hosts", () => { ), ); + it.effect("says why device_screenshot failed instead of a generic error", () => + run([session("mac-mini", "MAC-UDID", "ios")], ({ call }) => + Effect.gen(function* () { + const wrongHost = yield* call("device_screenshot", { + deviceId: "MAC-UDID", + hostId: "local", + }); + expect(wrongHost.isError).toBe(true); + expect(text(wrongHost)).toContain("It is on 'Mac Mini' (hostId mac-mini)"); + }), + ), + ); + + it.effect("tells an agent with no open device to call device_open before a screenshot", () => + run([], ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_screenshot", {}); + expect(result.isError).toBe(true); + expect(text(result)).toBe("No device is open in this thread. Call device_open first."); + expect(result.structuredContent).toMatchObject({ + error: { _tag: "DeviceToolUnavailableError" }, + }); + }), + ), + ); + it.effect("refuses to close an id open on two hosts instead of closing both", () => run( [session("local", "Pixel_10", "android"), session("mac-mini", "Pixel_10", "android")], diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 1173b4cdcade7..4365267e2144d 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -14,6 +14,7 @@ import type * as Types from "effect/Types"; import { AiError, McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { + DeviceToolError, OrchestratorMcpFailure, PreviewAutomationError, ProviderInteractionMode, @@ -765,11 +766,15 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh Effect.provideService(ThreadManagementService.ThreadManagementService, threads), ), "screenshot", - "Device screenshot failed.", + (error) => + isDeviceToolError(error) || isOrchestratorMcpFailure(error) + ? error.message + : "Device screenshot failed.", ); }); const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); +const isDeviceToolError = Schema.is(DeviceToolError); const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () { const htmlRender = yield* HtmlRender.HtmlRender; diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts index bfce0a9031d9b..6de9f15125d45 100644 --- a/packages/contracts/src/device.test.ts +++ b/packages/contracts/src/device.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; import { + DeviceBootError, DeviceServiceState, EasDeviceHostConfig, EasDeviceHostConfigs, @@ -79,3 +80,28 @@ describe("device state for clients from before EAS hosts", () => { expect(decodeState(wire).hosts).toEqual(state.hosts); }); }); + +describe("device boot errors", () => { + it("gives a next step instead of blaming the environment server when the host sent no details", () => { + const message = new DeviceBootError({ + hostId: "mac", + deviceId: "Pixel_10", + reason: "launch_failed", + cause: null, + }).message; + expect(message).not.toContain("environment server"); + expect(message).toContain("Try again or open another device"); + }); + + it("keeps the host's diagnostics when it sent them", () => { + const message = new DeviceBootError({ + hostId: "mac", + deviceId: "Pixel_10", + reason: "launch_failed", + diagnostics: "emulator: PANIC: Missing emulator engine program for 'x86_64' CPU.", + cause: null, + }).message; + expect(message).toContain("PANIC: Missing emulator engine program"); + expect(message).not.toContain("Try again"); + }); +}); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 0fc7fde4c3bd4..78d03db6abe75 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -492,13 +492,14 @@ export class DeviceBootError extends Schema.TaggedError()("Devi }) { override get message(): string { const explanation = { - disk_space: "There is not enough free disk space on the environment server.", + disk_space: "There is not enough free disk space on its device host.", timeout: "The device did not become ready in time.", - launch_failed: - "The simulator or emulator could not start. Check its configuration on the environment server.", + launch_failed: "The simulator or emulator could not start.", }[this.reason]; const summary = `Device ${this.deviceId} failed to boot: ${explanation}`; - return this.diagnostics ? `${summary}\n\n${this.diagnostics}` : summary; + return this.diagnostics + ? `${summary}\n\n${this.diagnostics}` + : `${summary} The device host sent no details. Try again or open another device; if it keeps failing, check the device on that host.`; } } From 981f92add8bd25b8e72d5ff5339516c56f0a4adc Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 21:42:39 -0400 Subject: [PATCH 03/30] fix(server): device_open returns an agent-device command bound to the device Agents had to repeat --platform, --udid, --config and --session on every agent-device call. Under zsh, the usual T="--platform ... --session ..." variable reached the launcher as a single argument, so the launcher replied "Call device_open first". It gave the same reply to `help gesture`, `open --help` and `record --help`. Every one of those traps sent agents back to simctl over ssh. device_open now writes a launcher for its agent-device session with the target flags built in, and returns that launcher. A flag the agent passes itself wins. Help and --help run with no device flags in both launchers, because agent-device reads --config even for help. When the runtime a launcher points at is gone, it says to call device_open again. The quickStart no longer says simctl, adb and xcrun "remain available for anything it does not cover", or that remote hosts must arrange builds, installs and Metro themselves. It lists the agent-device verbs agents reached for ssh to get: open --relaunch, deep links, alert, settings, record. simctl, adb and ssh stay for builds, installs, sync, Metro, forwards, logs, test runners and what agent-device cannot do. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/AgentDeviceShim.ts | 104 ++++++++++++------ .../src/device/AgentDeviceTarget.test.ts | 94 +++++++++++++++- apps/server/src/device/DeviceService.ts | 8 +- apps/server/src/mcp/McpDeviceToolkit.test.ts | 25 ++++- .../src/mcp/toolkits/device/handlers.test.ts | 10 ++ .../src/mcp/toolkits/device/handlers.ts | 80 +++++++------- apps/server/src/mcp/toolkits/device/tools.ts | 2 +- 7 files changed, 247 insertions(+), 76 deletions(-) diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index 4a84b895ee3e1..f41a7e9c96890 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -6,49 +6,91 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; const SHIM_DIR = "device/bin"; +const TARGETS_DIR = "device/targets"; +const STALE_RUNTIME = + "This agent-device command is out of date because T3 Code moved or updated its runtime. Call device_open again for a fresh one."; -export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: { - readonly entryPath: string; - readonly stateDir: string; -}) { - const { entryPath } = input; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcess.Platform; - const node = yield* resolveNodeExecutable("Device automation"); - const shimDir = path.join(input.stateDir, SHIM_DIR); - yield* fs.makeDirectory(shimDir, { recursive: true }); - const launcherPath = path.join(shimDir, "agent-device-launcher.mjs"); - yield* fs.writeFileString( - launcherPath, - `import { spawn } from "node:child_process"; +const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; + +// Help needs no device, and agent-device reads --config even for help, so help gets no bound flags. +const launcherSource = (node: string, entryPath: string, boundArgs: ReadonlyArray) => + `import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +const entry = ${JSON.stringify(entryPath)}; +const bound = ${JSON.stringify(boundArgs)}; const args = process.argv.slice(2); -const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]); -const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); }; +const informational = args.length === 0 || ["help", "--help", "-h", "--version", "version"].includes(args[0]) || args.includes("--help") || args.includes("-h"); +const command = [...args]; +if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!args.includes(bound[index])) command.push(bound[index], bound[index + 1]); +const hasValue = flag => { const index = command.indexOf(flag); return index >= 0 && !!command[index + 1] && !command[index + 1].startsWith("--"); }; if (!informational && !(hasValue("--config") && hasValue("--session"))) { - console.error("Call device_open first and include its --config and --session flags."); + console.error("Call device_open first and run the agent-device command it returns."); + process.exit(1); +} +if (!existsSync(entry)) { + console.error(${JSON.stringify(STALE_RUNTIME)}); process.exit(1); } const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" }; delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; -const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env }); +const child = spawn(${JSON.stringify(node)}, [entry, ...command], { stdio: "inherit", env }); child.on("error", error => { console.error(error.message); process.exitCode = 1; }); child.on("exit", code => { process.exitCode = code ?? 1; }); -`, - ); +`; + +/** Writes a launcher into `dir` and returns the executable agents run. */ +const writeLauncher = Effect.fn("AgentDeviceShim.writeLauncher")(function* (input: { + readonly dir: string; + readonly entryPath: string; + readonly boundArgs: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const node = yield* resolveNodeExecutable("Device automation"); + yield* fs.makeDirectory(input.dir, { recursive: true }); + const launcherPath = path.join(input.dir, "agent-device-launcher.mjs"); + yield* fs.writeFileString(launcherPath, launcherSource(node, input.entryPath, input.boundArgs)); if (platform === "win32") { - const script = `@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${node}" "${launcherPath}" %*\r\n`; - yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script); - } else { - const command = [node, launcherPath] - .map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'") - .join(" "); - const script = `#!/bin/sh\nexport ELECTRON_RUN_AS_NODE=1\nexec ${command} "$@"\n`; - const shimPath = path.join(shimDir, "agent-device"); - yield* fs.writeFileString(shimPath, script); - yield* fs.chmod(shimPath, 0o755); + const script = `@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\nif not exist "${node}" (echo ${STALE_RUNTIME} 1>&2 & exit /b 1)\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${node}" "${launcherPath}" %*\r\n`; + const scriptPath = path.join(input.dir, "agent-device.cmd"); + yield* fs.writeFileString(scriptPath, script); + return scriptPath; } + const script = `#!/bin/sh\n[ -x ${quoteShell(node)} ] || { echo ${quoteShell(STALE_RUNTIME)} >&2; exit 1; }\nexport ELECTRON_RUN_AS_NODE=1\nexec ${quoteShell(node)} ${quoteShell(launcherPath)} "$@"\n`; + const scriptPath = path.join(input.dir, "agent-device"); + yield* fs.writeFileString(scriptPath, script); + yield* fs.chmod(scriptPath, 0o755); + return scriptPath; +}); + +/** The unbound launcher: every call needs the device's target flags. */ +export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: { + readonly entryPath: string; + readonly stateDir: string; +}) { + const path = yield* Path.Path; + const shimDir = path.join(input.stateDir, SHIM_DIR); + yield* writeLauncher({ dir: shimDir, entryPath: input.entryPath, boundArgs: [] }); return shimDir; }); + +/** + * A launcher for one agent-device session with its target flags built in, so + * agents pass none, and a shell that does not word-split a variable cannot mangle them. + */ +export const bindAgentDeviceLauncher = Effect.fn("AgentDeviceShim.bind")(function* (input: { + readonly entryPath: string; + readonly stateDir: string; + readonly session: string; + readonly targetArgs: ReadonlyArray; +}) { + const path = yield* Path.Path; + return yield* writeLauncher({ + dir: path.join(input.stateDir, TARGETS_DIR, input.session), + entryPath: input.entryPath, + boundArgs: input.targetArgs, + }); +}); diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 24a71c34c4e48..0522d68593b92 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -7,7 +7,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts"; +import { bindAgentDeviceLauncher, ensureAgentDeviceShim } from "./AgentDeviceShim.ts"; import { agentDeviceConfigPath, agentDeviceSession, @@ -67,6 +67,98 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("binds one device's flags into its launcher and leaves help alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-bound-" }); + const entryPath = path.join(dir, "cli.mjs"); + yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));"); + const target = [ + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + "--session", + "t3-a", + ]; + const command = yield* bindAgentDeviceLauncher({ + entryPath, + stateDir: dir, + session: "t3-a", + targetArgs: target, + }); + const shared = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir }); + const launch = (launcher: string, args: ReadonlyArray) => + exec(process.execPath, [path.join(launcher, "agent-device-launcher.mjs"), ...args]).then( + (result) => JSON.parse(result.stdout), + ); + const bound = path.dirname(command); + expect(yield* Effect.promise(() => launch(bound, ["snapshot", "-i"]))).toEqual([ + "snapshot", + "-i", + ...target, + ]); + // A flag the agent passes itself wins over the bound one. + expect(yield* Effect.promise(() => launch(bound, ["open", "--session", "mine"]))).toEqual([ + "open", + "--session", + "mine", + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + ]); + for (const args of [["help", "gesture"], ["open", "--help"], ["record", "-h"], []]) { + expect(yield* Effect.promise(() => launch(bound, args))).toEqual(args); + expect(yield* Effect.promise(() => launch(shared, args))).toEqual(args); + } + yield* fs.remove(entryPath); + yield* Effect.promise(() => + expect(launch(bound, ["snapshot"])).rejects.toThrow("Call device_open again"), + ); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "tells the agent to reopen the device when the runtime it was bound to is gone", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-stale-" }); + const runtime = path.join(dir, "old-node"); + yield* fs.writeFileString(runtime, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(runtime, 0o755); + const command = yield* bindAgentDeviceLauncher({ + entryPath: path.join(dir, "cli.mjs"), + stateDir: dir, + session: "t3-a", + targetArgs: ["--config", "/h.json", "--session", "t3-a"], + }).pipe(Effect.provideService(HostProcess.ExecutablePath, runtime)); + yield* fs.remove(runtime); + yield* Effect.promise(() => + expect(exec(command, ["snapshot"])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("Call device_open again"), + }), + ); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provide(NodeServices.layer), + ), + ); + it.effect("runs two hosts concurrently and only updates the reconnected host", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d685dc46abf68..d3a84bb310c48 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -121,7 +121,11 @@ export class DeviceService extends Context.Service< hostId: DeviceHostId; deviceId: DeviceId; }) => Effect.Effect< - { readonly agentDeviceId: string; readonly args: ReadonlyArray }, + { + readonly agentDeviceId: string; + readonly session: string; + readonly args: ReadonlyArray; + }, DeviceError >; readonly state: Effect.Effect; @@ -875,7 +879,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* input.hostId, input.deviceId, ).pipe(Effect.provideService(Crypto.Crypto, crypto)); - return { agentDeviceId, args: ["--config", configPath, "--session", session] }; + return { agentDeviceId, session, args: ["--config", configPath, "--session", session] }; }), state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), subscribe: PubSub.subscribe(statePubSub), diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index a2fddaa532f26..5b263998c4d34 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -8,6 +8,8 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import { McpSchema, McpServer } from "effect/ai"; @@ -139,6 +141,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ agentTarget: () => Effect.succeed({ agentDeviceId: "SIM-UDID", + session: "thread-device", args: ["--config", "/host.json", "--session", "thread-device"], }), }); @@ -166,8 +169,25 @@ it.effect("registers the device tools and returns the screenshot as image conten .callTool({ name: "device_open", arguments: { platform: "ios" } }) .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); expect(opened.isError).toBe(false); - const openedContent = opened.structuredContent as { quickStart: string }; - expect(openedContent.quickStart).toContain("--udid SIM-UDID"); + const openedContent = opened.structuredContent as { + agentDevice: { command: string; targetArgs: ReadonlyArray }; + quickStart: string; + }; + expect(openedContent.agentDevice.targetArgs).toEqual([]); + expect(openedContent.quickStart).toContain( + `${openedContent.agentDevice.command} snapshot -i`, + ); + // The launcher selects the device by agent-device's id for it, not T3's. + const launcher = yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString( + path.join(path.dirname(openedContent.agentDevice.command), "agent-device-launcher.mjs"), + ); + }).pipe(Effect.provide(NodeServices.layer)); + expect(launcher).toContain( + '"--udid","SIM-UDID","--config","/host.json","--session","thread-device"', + ); const shot = yield* server .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-1" } }) @@ -318,6 +338,7 @@ describe("device targeting across hosts", () => { agentTarget: () => Effect.succeed({ agentDeviceId: "MAC-UDID", + session: "thread-device", args: ["--config", "/mac.json", "--session", "thread-device"], }), }); diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 0ad4b95581b2d..50c9298432277 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -26,6 +26,16 @@ describe("device tool helpers", () => { expect(text).not.toContain("is on PATH"); }); + it("shows a bound launcher's commands without flags and keeps device actions off simctl and ssh", () => { + const command = "/state/device/targets/t3-a/agent-device"; + const text = agentDeviceQuickStart(device, [], command); + const examples = text.split("\n").filter((line) => line.trimStart().startsWith(command)); + expect(examples).toContain(` ${command} snapshot -i # accessibility tree with @eN refs`); + expect(examples.join("\n")).not.toMatch(/--(?:platform|udid|serial|config|session)\b/); + expect(text).not.toContain("remain available"); + expect(text).toContain("Do not use simctl, idb, adb or ssh for those actions."); + }); + it("reads PNG dimensions from the IHDR chunk", () => { const png = new Uint8Array(24); new DataView(png.buffer).setUint32(0, 0x89504e47); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index db3287a69cc7d..12b4e2983bcd6 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -10,10 +10,8 @@ import { LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../../../config.ts"; -import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; +import { bindAgentDeviceLauncher, ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "../../../device/DeviceService.ts"; @@ -28,20 +26,19 @@ function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray { : ["--platform", "android", "--serial", device.id]; } +const shellWord = (value: string) => + /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : "'" + value.replaceAll("'", "'\"'\"'") + "'"; + +/** `targetArgs` are the flags the command still needs; a bound launcher needs none. */ export function agentDeviceQuickStart( device: DeviceSummary, targetArgs = agentDeviceTargetArgs(device), command = "agent-device", cloud = false, ): string { - const executable = /^[a-zA-Z0-9_./:-]+$/.test(command) - ? command - : "'" + command.replaceAll("'", "'\"'\"'") + "'"; - const target = targetArgs - .map((arg) => - /^[a-zA-Z0-9_./:-]+$/.test(arg) ? arg : "'" + arg.replaceAll("'", "'\"'\"'") + "'", - ) - .join(" "); + const executable = shellWord(command); + const target = targetArgs.map(shellWord).join(" "); + const run = (args: string) => [executable, args, target].filter(Boolean).join(" "); const platformNotes = cloud ? "This is an EAS Simulator session in the cloud. It bills the Expo account per minute until it stops, so call device_close with shutdown: true when you are done. It starts with no apps installed: install-from-source installs a build from a URL (iOS needs a .tar.gz containing a .app), and install uploads one from this machine." : device.platform === "ios" @@ -49,18 +46,23 @@ export function agentDeviceQuickStart( : "The Android snapshot helper installs itself on first use."; return [ `The user is watching ${device.name} (${device.version}) in the Device panel.`, - `Drive it with ${executable}. Use this exact executable path; login shells may reset PATH. Always pass ${target}.`, - "Typical loop:", - ` ${executable} open ${target} # or: open `, - ` ${executable} snapshot -i ${target} # accessibility tree with @eN refs`, - ` ${executable} click @e3 ${target}`, - ` ${executable} fill @e5 "text" ${target}`, - ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`, - ` ${executable} install ${target}`, - `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} --help for flags.`, - "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.", - "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.", - "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.", + target === "" + ? `Drive it with ${executable}. It is bound to this device and session, so pass no --platform, --udid, --serial, --config or --session flags. Use this exact path; login shells may reset PATH.` + : `Drive it with ${executable}. Use this exact path; login shells may reset PATH. Always pass ${target}.`, + "Every app launch, deep link, tap, text entry, alert, setting, screenshot and recording on this device goes through it:", + ` ${run("open --relaunch")} # restarts the app; Expo dev clients connect to their Metro, --metro-port picks it`, + ` ${run("open ")}`, + ` ${run("snapshot -i")} # accessibility tree with @eN refs`, + ` ${run("press @e3 --settle")}`, + ` ${run('fill @e5 "text" --settle')}`, + ` ${run("alert accept")}`, + ` ${run("settings appearance dark")} # also text-size, permission grant `, + ` ${run("screenshot /tmp/shot.png")} # or call device_screenshot to look`, + ` ${run("record start /tmp/run.mp4")} # then record stop`, + ` ${run("install ")}`, + `Prefer snapshot refs over coordinates. Run ${executable} help for flags and ${executable} help workflow for guides.`, + "Do not use simctl, idb, adb or ssh for those actions. They stay fine for builds, installs, code sync, Metro, port forwards, logs, test runners and what this command cannot do; come back to it for every UI action and screenshot so the user can watch.", + "Each device_open returns its own command; opening another device does not retarget this one.", platformNotes, ].join("\n"); } @@ -244,17 +246,21 @@ const handlers = { deviceId: device.id, }); const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); - const targetArgs = [ - ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), - ...agent.args, - ]; const config = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; - const platform = yield* HostProcess.Platform; - const shimDir = yield* ensureAgentDeviceShim({ - entryPath: yield* devices.agentCli, - stateDir: config.stateDir, - }).pipe( + const entryPath = yield* devices.agentCli; + // The unbound launcher stays current for agents that still call it with flags. + const command = yield* ensureAgentDeviceShim({ entryPath, stateDir: config.stateDir }).pipe( + Effect.andThen( + bindAgentDeviceLauncher({ + entryPath, + stateDir: config.stateDir, + session: agent.session, + targetArgs: [ + ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), + ...agent.args, + ], + }), + ), Effect.mapError( (error) => new DeviceToolUnavailableError({ @@ -266,14 +272,10 @@ const handlers = { }), ), ); - const command = path.join( - shimDir, - platform === "win32" ? "agent-device.cmd" : "agent-device", - ); return { device, - agentDevice: { command, targetArgs }, - quickStart: agentDeviceQuickStart(device, targetArgs, command, cloud), + agentDevice: { command, targetArgs: [] }, + quickStart: agentDeviceQuickStart(device, [], command, cloud), }; }).pipe(Effect.mapError(toolError)), ), diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 1cf393ad8c2c4..f15be60fbcef1 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -49,7 +49,7 @@ const DeviceListTool = Tool.make("device_list", { const DeviceOpenTool = Tool.make("device_open", { description: - "Open a simulator or emulator for this thread. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.", + "Open a simulator or emulator, on this machine or a device host, for this thread. Boots it if needed and shows it in the user's Device panel. Returns an `agent-device` command bound to that device: run it with no device flags for every app launch, tap, text entry, screenshot and recording, instead of simctl, idb, adb or ssh.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolFailure, From 3558e7e8b2fb5a2809ac8f8adf7fa3771f036f70 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 21:51:04 -0400 Subject: [PATCH 04/30] feat(server): every provider session with device access gets the device instructions Only Codex was told the device manager exists. Claude sessions got nothing, and T3 tools reach Claude as deferred names behind ToolSearch. In the investigation, 26 of 50 threads that touched the Mac never used the device tools, and 0 of 31 delegated task prompts mentioned them. The device block now lives in provider-core: - T3_CODE_DEVICE_TOOL_INSTRUCTIONS, the provider-neutral block. - T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, the same block plus the ToolSearch line Claude needs to load the tools. A session gets the block whenever it holds `device`. Delegated children and launched threads open their own provider sessions with their project's device access, so they receive it too, whatever their parent wrote. A test pins that. The block also tells a parent to point a child at device_open instead of writing ssh or simctl steps into its task. What the block says: - Simulator and emulator UI work on local and SSH device hosts goes through the device tools. - ssh, simctl and adb stay for builds, installs, sync, Metro, forwards, logs, the project's test runners and what agent-device cannot do. - It overrides memory notes, docs and delegated tasks; only an explicit user request sends device actions over ssh. - When a device tool still fails after following its advice once, the agent tells the user and stops. Per adapter: - Claude: system prompt append. - Codex: the t3_code_tools additional context. - ACP (Grok, Antigravity, registry agents): the first-prompt instructions, re-sent when device access changes. - Cursor: the first-run prefix. - OpenCode 1.x and 2.x: system instructions. - Pi and Muse: unchanged. Pi's extension embeds static instructions and cannot see the capability. Muse gets no T3 instructions today. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 33 +++++++++++ .../Adapters/ClaudeAdapterV2.ts | 17 +++++- .../ProviderSessionManager.test.ts | 38 +++++++++++-- .../CodexDeveloperInstructions.test.ts | 13 +++++ .../provider/CodexDeveloperInstructions.ts | 5 +- docs/internals/devices.md | 16 ++++-- packages/provider-acp/src/server/adapter.ts | 4 +- .../server/orchestrationInstructions.test.ts | 56 +++++++++++++++++++ .../src/server/orchestrationInstructions.ts | 43 ++++++++++++-- .../provider-cursor/src/server/adapter.ts | 10 +++- .../provider-opencode/src/server/adapter.ts | 5 +- .../src/server/v2/adapter.ts | 2 +- 12 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 packages/provider-core/src/server/orchestrationInstructions.test.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9f2e601afa382..23281614df1c3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -79,6 +79,7 @@ import { attachmentRelativePath } from "../../attachmentStore.ts"; import { WorkflowAgentTranscriptReadObserver } from "../workflowAgentTranscript.ts"; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH } from "@t3tools/provider-core/server/orchestrationInstructions"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; @@ -603,6 +604,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => { assert.equal(systemPrompt.preset, "claude_code"); assert.include(systemPrompt.append ?? "", "Use `delegate_task`"); assert.include(systemPrompt.append ?? "", "call `t3_background_wait`"); + assert.notInclude(systemPrompt.append ?? "", "## T3 Code devices"); const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options); assert.equal(logged.hasMcpServers, true); assert.notInclude(JSON.stringify(logged), "secret-claude-token"); @@ -622,6 +624,37 @@ describe("ClaudeAdapterV2 native protocol logging", () => { }); }); +describe("ClaudeAdapterV2 device instructions", () => { + const append = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) => + ( + ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", + }, + nativeThreadId: "native-thread-claude-devices", + resume: false, + cwd: "/workspace", + ...(input.mcp + ? { mcpServers: { "t3-code": { type: "http" as const, url: "http://127.0.0.1/mcp" } } } + : {}), + deviceToolsAvailable: input.deviceToolsAvailable, + environment: {}, + }).systemPrompt as { readonly append?: string } + ).append ?? ""; + + it("gives a session with device access the device block and the ToolSearch line to load it", () => { + const text = append({ mcp: true, deviceToolsAvailable: true }); + assert.include(text, T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH); + assert.include(text, "Use `delegate_task`"); + }); + + it("leaves the device block out without device access or without T3 tools", () => { + assert.notInclude(append({ mcp: true, deviceToolsAvailable: false }), "## T3 Code devices"); + assert.notInclude(append({ mcp: false, deviceToolsAvailable: true }), "## T3 Code devices"); + }); +}); + describe("ClaudeAdapterV2 context usage", () => { it("dates cache expiry from the request start, not the response end", () => { const usage = ClaudeAdapterV2.claudeProviderTurnTokenUsage( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5c676d684f686..b7c0b41f77424 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -167,7 +167,10 @@ import { } from "../../provider/claudeUsageLimits.ts"; import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + T3_CODE_ORCHESTRATION_INSTRUCTIONS, +} from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { mcpToolPresentation, @@ -1442,6 +1445,8 @@ export function makeClaudeQueryOptions(input: { readonly sdkSettings?: string | ClaudeSdkSettings; readonly environment?: NodeJS.ProcessEnv; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; + /** The thread's T3 MCP session holds the `device` capability. */ + readonly deviceToolsAvailable?: boolean; readonly tools?: ClaudeAgentSdkQueryTools; readonly allowedTools?: ReadonlyArray; readonly disallowedTools?: ReadonlyArray; @@ -1558,7 +1563,11 @@ export function makeClaudeQueryOptions(input: { buildRuntimeInstructions({ harness: "Claude Code" }) + (input.mcpServers === undefined ? "" - : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS), + : T3_CODE_ORCHESTRATION_INSTRUCTIONS + + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS + + (input.deviceToolsAvailable === true + ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n` + : "")), ...launchAppendSystemPrompt, ].join("\n\n"), }, @@ -9689,8 +9698,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId: string, ) { const queryPolicy = yield* queryPolicyForTurn(turnInput); + const mcpSession = yield* mcpSessions.read(turnInput.threadId); const mcpOverrides = claudeMcpQueryOverrides({ - mcpSession: yield* mcpSessions.read(turnInput.threadId), + mcpSession, readOnlySandbox: sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) === "readOnly", ...(queryPolicy.allowedTools === undefined @@ -9826,6 +9836,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(mcpOverrides.mcpServers === undefined ? {} : { mcpServers: mcpOverrides.mcpServers }), + deviceToolsAvailable: mcpSession?.capabilities?.has("device") === true, permissionMode: queryPolicy.permissionMode, ...(queryPolicy.allowDangerouslySkipPermissions === undefined ? {} diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 8398fca9cb6a5..9ae9ddce81a99 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -225,6 +225,7 @@ function makeThreadCreatedEvent(input: { readonly threadId: ThreadId; readonly now: DateTime.Utc; readonly projectId?: ProjectId; + readonly delegatedBy?: ThreadId; }) { return Effect.gen(function* () { const projectId = @@ -249,11 +250,14 @@ function makeThreadCreatedEvent(input: { branch: null, worktreePath: null, activeProviderThreadId: providerThreadId, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: input.threadId, - }, + lineage: + input.delegatedBy === undefined + ? { parentThreadId: null, relationshipToParent: null, rootThreadId: input.threadId } + : { + parentThreadId: input.delegatedBy, + relationshipToParent: "subagent", + rootThreadId: input.delegatedBy, + }, forkedFrom: null, createdAt: input.now, updatedAt: input.now, @@ -633,6 +637,7 @@ function runBrowserAccessScenario(input: { readonly deviceOverride?: boolean; readonly createThread?: boolean; readonly projectExists?: boolean; + readonly delegatedBy?: ThreadId; }) { return Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -661,7 +666,15 @@ function runBrowserAccessScenario(input: { }); if (input.createThread !== false) { yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now, projectId })], + events: [ + yield* makeThreadCreatedEvent({ + idAllocator, + threadId, + now, + projectId, + ...(input.delegatedBy === undefined ? {} : { delegatedBy: input.delegatedBy }), + }), + ], }); } yield* manager @@ -3628,3 +3641,16 @@ it.effect( assert.isFalse(denied?.capabilities?.has("device")); }), ); + +// Device instructions reach delegated children only because their own sessions hold `device`. +it.effect("ProviderSessionManagerV2 gives a delegated child its project's device access", () => + Effect.gen(function* () { + const child = yield* runBrowserAccessScenario({ + enableAgentBrowserAccess: false, + projectOverride: false, + deviceOverride: true, + delegatedBy: ThreadId.make("thread-provider-session-manager-parent"), + }); + assert.isTrue(child?.capabilities?.has("device")); + }), +); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 92e242a7ad714..87473e8da2f11 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -1,5 +1,6 @@ import * as NodeAssert from "node:assert/strict"; +import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import { describe, it } from "vite-plus/test"; import { @@ -76,6 +77,18 @@ describe("Codex orchestration context", () => { }); }); +describe("Codex device context", () => { + it("adds the shared device block only when the session holds device access", () => { + const tools = (device: boolean) => + buildCodexAdditionalContext({ model: "gpt-6.1-sol" }, { browser: true, device }).t3_code_tools + ?.value ?? ""; + + NodeAssert.ok(tools(true).includes(T3_CODE_DEVICE_TOOL_INSTRUCTIONS)); + NodeAssert.ok(!tools(true).includes("ToolSearch")); + NodeAssert.ok(!tools(false).includes("## T3 Code devices")); + }); +}); + function runtimeInstructions(runtime: Parameters[0]) { return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 3ec4c33a0a3f1..cbfd514eb11a6 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -3,16 +3,13 @@ import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeI import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, T3_CODE_ORCHESTRATION_SECTIONS, } from "@t3tools/provider-core/server/orchestrationInstructions"; /** Codex silently truncates additionalContext values over 1,000 tokens, counted as UTF-8 bytes / 4. */ export const CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES = 4_000; -const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices - -For iOS Simulators and Android Emulators here, call \`device_list\`, then \`device_open\`. Drive the device with the exact \`agent-device\` launcher and flags it returns, on every command. Prefer \`agent-device snapshot -i\` refs over coordinates. Use \`device_screenshot\` to see the screen. Use \`xcrun simctl\` or \`adb\` only for builds, logs, and ports. If a platform is unavailable, say so.`; - export interface T3CodeToolAvailability { readonly browser: boolean; readonly device: boolean; diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 0f5f1556d850b..6bb49018223cc 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -91,11 +91,19 @@ enabled, the session has the `device` capability, and the machine can run at least one platform. Starting it later from `device_open` would leave the already-running agent without the CLI. -How to drive a device is returned from `device_open`, not kept in an +The full command loop is returned from `device_open`, not kept in an always-loaded prompt or skill: it costs nothing in threads that never open a -device and cannot drift from the pinned CLI version. The always-on prompt block -is a few lines that point at the tools and prefer them over raw `simctl` and -`adb`, which stay available for what the tools do not cover. +device and cannot drift from the pinned CLI version. `device_open` returns a +launcher bound to its agent-device session, so agents pass no target flags. + +Agents choose between these tools and ssh before they open anything, so the +always-on block in +[`orchestrationInstructions.ts`](../../packages/provider-core/src/server/orchestrationInstructions.ts) +goes to every provider session that holds `device`. That includes delegated +children, which otherwise see only their task. The block routes simulator and +emulator UI work on local and SSH hosts to the tools, and it names the few verbs +agents used to reach `simctl` for. It leaves ssh for builds, installs, sync, +Metro, port forwards and logs. ## The viewer decodes both vendored protocols diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 7e7f43fa632cc..b88e31d203948 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -7171,9 +7171,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( sessionId: string, ) { const prompt: Array = []; + const mcpSession = yield* mcpSessions.read(turnInput.threadId); const instructionState = { interactionMode: turnInput.runtimePolicy.interactionMode, - hasT3Mcp: (yield* readMcpContext(turnInput.threadId)).servers.length > 0, + hasT3Mcp: acpMcpContext(mcpSession, self).servers.length > 0, + hasDeviceTools: mcpSession?.capabilities?.has("device") === true, } satisfies T3AcpInstructionState; const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); const compactionMarker = (yield* Ref.get(compactionMarkers)).get(sessionId); diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts new file mode 100644 index 0000000000000..fb4ba6f66bae7 --- /dev/null +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -0,0 +1,56 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + t3AcpPromptWithInstructions, + t3OrchestrationPromptForFirstRun, + t3OrchestrationSystemPrompt, +} from "./orchestrationInstructions.ts"; + +describe("T3 device instructions", () => { + it("route device work through device_open and give Claude the line that loads the tools", () => { + for (const intent of ["`device_list`", "`device_open`", "`agent-device`", "ssh", "`hostId`"]) { + assert.include(T3_CODE_DEVICE_TOOL_INSTRUCTIONS, intent); + } + assert.notInclude(T3_CODE_DEVICE_TOOL_INSTRUCTIONS, "ToolSearch"); + const loadLine = + " Load them with ToolSearch `select:mcp__t3-code__device_list,mcp__t3-code__device_open,mcp__t3-code__device_screenshot,mcp__t3-code__device_close`."; + assert.include(T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, loadLine); + assert.equal( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH.replace(loadLine, ""), + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + ); + }); + + it("reach OpenCode and Cursor only when the session holds device access", () => { + assert.include(t3OrchestrationSystemPrompt(true, true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(t3OrchestrationSystemPrompt(true, false), "## T3 Code devices"); + assert.isUndefined(t3OrchestrationSystemPrompt(false, true)); + + const firstRun = (hasDeviceTools: boolean) => + t3OrchestrationPromptForFirstRun({ + prompt: "Check the login screen.", + runOrdinal: 1, + hasT3Mcp: true, + hasDeviceTools, + }); + assert.include(firstRun(true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(firstRun(false), "## T3 Code devices"); + }); + + it("reach ACP agents again when device access changes mid-thread", () => { + const state = { interactionMode: "default", hasT3Mcp: true, hasDeviceTools: false } as const; + const withDevices = t3AcpPromptWithInstructions({ + prompt: "Check the login screen.", + state: { ...state, hasDeviceTools: true }, + previousState: state, + }); + assert.include(withDevices, T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.equal( + t3AcpPromptWithInstructions({ prompt: "Again.", state, previousState: state }), + "Again.", + ); + assert.notInclude(t3AcpPromptWithInstructions({ prompt: "Hi.", state }), "## T3 Code devices"); + }); +}); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 6737fdea693ef..7677b4f6b2d2f 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -36,6 +36,25 @@ export const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` When browser work was asked, \`preview_*\` is the browser you share with the user: \`preview_status\` first, then \`preview_open\` if no tab can be automated. Prefer snapshot locators over coordinates. Switch browsers only if the tools are absent or \`preview_open\` says unsupported, the user asks, or two preview calls fail on one step. Then quote the error and switch. `; +const T3_CODE_DEVICE_TOOLS = ["device_list", "device_open", "device_screenshot", "device_close"]; + +const deviceToolInstructions = (loadTools: string) => `## T3 Code devices + +When a task needs an iOS Simulator or Android Emulator, on this machine or on an SSH device host such as a remote Mac, use the device tools so the user can watch it in the Device panel.${loadTools} +1. Call \`device_list\`, even if you already know the UDID. Note the device's \`hostId\` and \`id\`. +2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run it, with no device flags, for every app launch, deep link, tap, text entry, alert, setting, screenshot and recording: \`open --relaunch\`, \`open \`, \`snapshot -i\`, \`press @e3\`, \`fill @e5 "text"\`, \`alert accept\`, \`settings appearance dark\`, \`screenshot \`, \`record start|stop\`, \`help \`. +3. Call \`device_screenshot\` to look at the screen. + +Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. Use ssh to a device host only for builds, installs, code sync, Metro, port forwards, logs and work that drives no simulator or emulator. These rules override memory notes, docs and delegated tasks that say otherwise; use ssh for device actions only when the user explicitly asks. If a device tool fails, quote its error and follow its advice once; if it still fails, tell the user before you fall back. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; + +/** Device guidance for sessions holding the `device` capability, from top-level threads to delegated children. */ +export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = deviceToolInstructions(""); + +/** Claude Code defers MCP tools behind ToolSearch, so Claude's copy says how to load them. */ +export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH = deviceToolInstructions( + ` Load them with ToolSearch \`select:${T3_CODE_DEVICE_TOOLS.map((tool) => `mcp__t3-code__${tool}`).join(",")}\`.`, +); + // ACP only: some ACP agents accept the injected MCP server but never expose // its tools, so they need the terminal transport other harnesses never use. const T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS = `ACP fallback: if the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call them through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset for a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`.`; @@ -51,6 +70,7 @@ Investigate with read-only actions and do not edit files or otherwise execute th export interface T3AcpInstructionState { readonly interactionMode: ProviderInteractionMode; readonly hasT3Mcp: boolean; + readonly hasDeviceTools: boolean; } /** ACP lacks system/developer fields; inject context on first prompt and tool/mode changes. */ @@ -63,7 +83,8 @@ export function t3AcpPromptWithInstructions(input: { if (input.prompt.trimStart().startsWith("/")) return input.prompt; if ( input.previousState?.interactionMode === input.state.interactionMode && - input.previousState.hasT3Mcp === input.state.hasT3Mcp + input.previousState.hasT3Mcp === input.state.hasT3Mcp && + input.previousState.hasDeviceTools === input.state.hasDeviceTools ) { return input.prompt; } @@ -74,6 +95,7 @@ export function t3AcpPromptWithInstructions(input: { ...(input.state.hasT3Mcp ? [ T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim(), + ...(input.state.hasDeviceTools ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS] : []), T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim(), T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS, ] @@ -82,20 +104,29 @@ export function t3AcpPromptWithInstructions(input: { return `\n${instructions.join("\n\n")}\n\n\n\n${input.prompt}\n`; } -function prependT3OrchestrationInstructions(prompt: string): string { - return `${T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()}\n\n\n${prompt}\n`; +const orchestrationInstructions = (hasDeviceTools: boolean) => + hasDeviceTools + ? `${T3_CODE_ORCHESTRATION_INSTRUCTIONS}\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS}\n` + : T3_CODE_ORCHESTRATION_INSTRUCTIONS; + +function prependT3OrchestrationInstructions(prompt: string, hasDeviceTools: boolean): string { + return `${orchestrationInstructions(hasDeviceTools).trim()}\n\n\n${prompt}\n`; } export function t3OrchestrationPromptForFirstRun(input: { readonly prompt: string; readonly runOrdinal: number; readonly hasT3Mcp: boolean; + readonly hasDeviceTools: boolean; }): string { return input.runOrdinal === 1 && input.hasT3Mcp - ? prependT3OrchestrationInstructions(input.prompt) + ? prependT3OrchestrationInstructions(input.prompt, input.hasDeviceTools) : input.prompt; } -export function t3OrchestrationSystemPrompt(hasT3Mcp: boolean): string | undefined { - return hasT3Mcp ? T3_CODE_ORCHESTRATION_INSTRUCTIONS : undefined; +export function t3OrchestrationSystemPrompt( + hasT3Mcp: boolean, + hasDeviceTools: boolean, +): string | undefined { + return hasT3Mcp ? orchestrationInstructions(hasDeviceTools) : undefined; } diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 915c646be3001..75c202b5b57a8 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2181,6 +2181,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const resolveUserMessage = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, mcpServers: Record | undefined, + hasDeviceTools: boolean, ) { const rawText = turnInput.message.text; if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { @@ -2212,6 +2213,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }), runOrdinal: turnInput.runOrdinal, hasT3Mcp: mcpServers !== undefined, + hasDeviceTools, }); const images = yield* Effect.forEach( turnInput.message.attachments.filter(isProviderNativeImageAttachment), @@ -2275,8 +2277,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( modelSelection: turnInput.modelSelection, runtimePolicy: turnInput.runtimePolicy, }); - const mcpServers = cursorMcpServers(yield* mcpSessions.read(turnInput.threadId)); - const message = yield* resolveUserMessage(turnInput, mcpServers); + const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const message = yield* resolveUserMessage( + turnInput, + cursorMcpServers(mcpSession), + mcpSession?.capabilities?.has("device") === true, + ); const pendingUpdates: Array = []; let context: ActiveCursorTurn | null = null; // MCP send overrides replace the ambient servers loaded when the agent opens. diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index bad3e8cd7b89a..ef85a7e70b29b 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -948,7 +948,10 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const mcpSession = yield* mcpSessions.read(input.threadId); const hasT3Mcp = mcpSession !== undefined && !connection.external; - const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); + const orchestrationSystemPrompt = t3OrchestrationSystemPrompt( + hasT3Mcp, + mcpSession?.capabilities?.has("device") === true, + ); if (hasT3Mcp) { yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => client.mcp.add({ diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 9c4ec57c34776..1f2dd94ca2112 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3051,7 +3051,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const instructions = [ buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), - t3OrchestrationSystemPrompt(mcpAvailable), + t3OrchestrationSystemPrompt(mcpAvailable, mcpSession?.capabilities?.has("device") === true), ] .filter((part) => part !== undefined && part.length > 0) .join("\n\n"); From 03fc16976d50f4d0d0838f95bb0225902415a90f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 21:54:08 -0400 Subject: [PATCH 05/30] fix(server): device_open's description asks for the hostId The deviceId and hostId field descriptions never reach agents. TrimmedString is a decode transformation, and tool JSON schemas come from its encoded side, so annotations on DeviceId and DeviceHostId fields are dropped. The only visible place to ask for the device_list hostId is the tool description. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/device/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index f15be60fbcef1..0a79af2efb0f6 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -49,7 +49,7 @@ const DeviceListTool = Tool.make("device_list", { const DeviceOpenTool = Tool.make("device_open", { description: - "Open a simulator or emulator, on this machine or a device host, for this thread. Boots it if needed and shows it in the user's Device panel. Returns an `agent-device` command bound to that device: run it with no device flags for every app launch, tap, text entry, screenshot and recording, instead of simctl, idb, adb or ssh.", + "Open a simulator or emulator for this thread. Pass `deviceId` and `hostId` from `device_list`. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolFailure, From 4c6a35031f1149cbb8072c2bc57da3823661141b Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 22:20:23 -0400 Subject: [PATCH 06/30] fix(server): the bound agent-device launcher reads flags only before -- Help detection, bound-flag insertion and the shared launcher's target check looked at every argument, so text after -- (a literal --help or --session) changed what ran, and bound flags appended after -- became text. A --flag=value override was not recognized, so the bound default overrode it. Flags are now read only before --, bound flags go before it, and --flag=value counts as passing the flag. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/AgentDeviceShim.ts | 11 ++++--- .../src/device/AgentDeviceTarget.test.ts | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index f41a7e9c96890..63c01e0794d00 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -13,16 +13,19 @@ const STALE_RUNTIME = const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; // Help needs no device, and agent-device reads --config even for help, so help gets no bound flags. +// Arguments after `--` are text, so only those before it are read as flags. const launcherSource = (node: string, entryPath: string, boundArgs: ReadonlyArray) => `import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; const entry = ${JSON.stringify(entryPath)}; const bound = ${JSON.stringify(boundArgs)}; const args = process.argv.slice(2); -const informational = args.length === 0 || ["help", "--help", "-h", "--version", "version"].includes(args[0]) || args.includes("--help") || args.includes("-h"); -const command = [...args]; -if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!args.includes(bound[index])) command.push(bound[index], bound[index + 1]); -const hasValue = flag => { const index = command.indexOf(flag); return index >= 0 && !!command[index + 1] && !command[index + 1].startsWith("--"); }; +const end = args.indexOf("--"); +const options = end < 0 ? args : args.slice(0, end); +const informational = args.length === 0 || ["help", "--help", "-h", "--version", "version"].includes(args[0]) || options.includes("--help") || options.includes("-h"); +if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!options.some(arg => arg === bound[index] || arg.startsWith(bound[index] + "="))) options.push(bound[index], bound[index + 1]); +const command = end < 0 ? options : [...options, ...args.slice(end)]; +const hasValue = flag => { const index = options.indexOf(flag); return options.some(arg => arg.startsWith(flag + "=") && arg.length > flag.length + 1) || (index >= 0 && !!options[index + 1] && !options[index + 1].startsWith("--")); }; if (!informational && !(hasValue("--config") && hasValue("--session"))) { console.error("Call device_open first and run the agent-device command it returns."); process.exit(1); diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 0522d68593b92..3aef80728c050 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -113,6 +113,36 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" "--config", "/h.json", ]); + expect(yield* Effect.promise(() => launch(bound, ["snapshot", "--session=mine"]))).toEqual([ + "snapshot", + "--session=mine", + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + ]); + // After `--` everything is text: no help, no overrides, and bound flags go before it. + for (const text of [["--help"], ["literal text", "--session", "mine"]]) { + expect(yield* Effect.promise(() => launch(bound, ["fill", "@e5", "--", ...text]))).toEqual([ + "fill", + "@e5", + ...target, + "--", + ...text, + ]); + } + expect( + yield* Effect.promise(() => + launch(shared, ["snapshot", "--config=/h.json", "--session=t3-a"]), + ), + ).toEqual(["snapshot", "--config=/h.json", "--session=t3-a"]); + yield* Effect.promise(() => + expect( + launch(shared, ["fill", "@e5", "--", "--config", "/h.json", "--session", "t3-a"]), + ).rejects.toThrow("Call device_open first"), + ); for (const args of [["help", "gesture"], ["open", "--help"], ["record", "-h"], []]) { expect(yield* Effect.promise(() => launch(bound, args))).toEqual(args); expect(yield* Effect.promise(() => launch(shared, args))).toEqual(args); From 1224d82a3a7d3764a591631e7cfae84532e8de28 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 22:24:18 -0400 Subject: [PATCH 07/30] fix(cursor): resumed and compacted conversations get the T3 instructions Cursor sent the T3 instructions only on a thread's first run, so a conversation compacted with /compress lost both the orchestration and device text, and one resumed after the update or granted device access later never received the device block. The adapter now records what each native conversation was told. After compaction the next run sends the full instructions again; a conversation that lacks only the device block gets the block alone. Co-Authored-By: Claude Opus 5.5 --- .../server/orchestrationInstructions.test.ts | 20 ++- .../src/server/orchestrationInstructions.ts | 21 ++- .../src/server/adapter.test.ts | 152 ++++++++++++++++++ .../provider-cursor/src/server/adapter.ts | 34 +++- 4 files changed, 213 insertions(+), 14 deletions(-) diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts index fb4ba6f66bae7..d1476e4d86613 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.test.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -3,8 +3,9 @@ import { assert, describe, it } from "@effect/vitest"; import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS, T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + T3_CODE_ORCHESTRATION_INSTRUCTIONS, t3AcpPromptWithInstructions, - t3OrchestrationPromptForFirstRun, + t3OrchestrationPrompt, t3OrchestrationSystemPrompt, } from "./orchestrationInstructions.ts"; @@ -29,14 +30,27 @@ describe("T3 device instructions", () => { assert.isUndefined(t3OrchestrationSystemPrompt(false, true)); const firstRun = (hasDeviceTools: boolean) => - t3OrchestrationPromptForFirstRun({ + t3OrchestrationPrompt({ prompt: "Check the login screen.", - runOrdinal: 1, hasT3Mcp: true, hasDeviceTools, + orchestrationDelivered: false, + deviceInstructionsDelivered: false, }); assert.include(firstRun(true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); assert.notInclude(firstRun(false), "## T3 Code devices"); + + const laterRun = (deviceInstructionsDelivered: boolean) => + t3OrchestrationPrompt({ + prompt: "Check the login screen.", + hasT3Mcp: true, + hasDeviceTools: true, + orchestrationDelivered: true, + deviceInstructionsDelivered, + }); + assert.include(laterRun(false), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(laterRun(false), T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()); + assert.equal(laterRun(true), "Check the login screen."); }); it("reach ACP agents again when device access changes mid-thread", () => { diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 7677b4f6b2d2f..474fcb2d15138 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -109,18 +109,27 @@ const orchestrationInstructions = (hasDeviceTools: boolean) => ? `${T3_CODE_ORCHESTRATION_INSTRUCTIONS}\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS}\n` : T3_CODE_ORCHESTRATION_INSTRUCTIONS; -function prependT3OrchestrationInstructions(prompt: string, hasDeviceTools: boolean): string { - return `${orchestrationInstructions(hasDeviceTools).trim()}\n\n\n${prompt}\n`; +function prependT3OrchestrationInstructions(prompt: string, instructions: string): string { + return `${instructions.trim()}\n\n\n${prompt}\n`; } -export function t3OrchestrationPromptForFirstRun(input: { +/** For providers whose instructions ride on the prompt, so a compacted conversation loses them. */ +export function t3OrchestrationPrompt(input: { readonly prompt: string; - readonly runOrdinal: number; readonly hasT3Mcp: boolean; readonly hasDeviceTools: boolean; + readonly orchestrationDelivered: boolean; + readonly deviceInstructionsDelivered: boolean; }): string { - return input.runOrdinal === 1 && input.hasT3Mcp - ? prependT3OrchestrationInstructions(input.prompt, input.hasDeviceTools) + if (!input.hasT3Mcp) return input.prompt; + if (!input.orchestrationDelivered) { + return prependT3OrchestrationInstructions( + input.prompt, + orchestrationInstructions(input.hasDeviceTools), + ); + } + return input.hasDeviceTools && !input.deviceInstructionsDelivered + ? prependT3OrchestrationInstructions(input.prompt, T3_CODE_DEVICE_TOOL_INSTRUCTIONS) : input.prompt; } diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 7ab3aa8c1a3be..5e7093b67ae21 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -586,6 +586,158 @@ describe("CursorAdapterV2", () => { ), ); + it.effect("sends T3 instructions to each Cursor conversation that lacks them", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-device-" }); + const sentMessages: Array = []; + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-device-thread"); + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const setDeviceAccess = (device: boolean) => + mcpSessions.set({ + environmentId: EnvironmentId.make("cursor-device-environment"), + threadId, + providerSessionId: "cursor-device-session", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer synthetic-cursor-token", + browserToolsAvailable: true, + capabilities: new Set(device ? ["device"] : []), + }); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const adapter = yield* makeCursorAdapterV2({ + instanceId, + environment: { HOME: workspace }, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-device", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.sync(() => { + sentMessages.push( + typeof input.message === "string" ? input.message : input.message.text, + ); + const runId = `native-cursor-run-${sentMessages.length}`; + return { + agentId: "native-cursor-device", + runId, + wait: Effect.succeed({ + id: runId, + requestId: `native-request-${sentMessages.length}`, + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + cancel: Effect.void, + }; + }), + }), + }), + Effect.provide(TestProviderHost.layer({ cwd: workspace })), + ); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-device-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const appThread = { + id: threadId, + projectId: ProjectId.make("cursor-device-project"), + createdBy: "user" as const, + creationSource: "web" as const, + title: "Cursor devices", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + // Starts at run 5: a conversation from before device instructions existed, now resumed. + let runOrdinal = 4; + const send = Effect.fnUntraced(function* (compact = false) { + runOrdinal += 1; + const turnInput = { + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make(`cursor-device-run-${runOrdinal}`), + runOrdinal, + providerTurnOrdinal: runOrdinal, + attemptId: RunAttemptId.make(`cursor-device-attempt-${runOrdinal}`), + rootNodeId: NodeId.make(`cursor-device-root-${runOrdinal}`), + appThread, + message: { + messageId: MessageId.make(`cursor-device-message-${runOrdinal}`), + createdBy: "user" as const, + creationSource: "web" as const, + text: "Open the app.", + attachments: [], + }, + }; + yield* compact ? runtime.compactThread!(turnInput) : runtime.startTurn(turnInput); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + const sent = sentMessages.at(-1)!; + return { + orchestration: sent.includes("## T3 Code orchestration"), + devices: sent.includes("## T3 Code devices"), + }; + }); + const told = (orchestration: boolean, devices: boolean) => ({ orchestration, devices }); + + yield* setDeviceAccess(true); + assert.deepEqual(yield* send(), told(false, true)); + assert.deepEqual(yield* send(), told(false, false)); + yield* send(true); + assert.deepEqual( + yield* send(), + told(true, true), + "compaction drops what the conversation was told", + ); + yield* setDeviceAccess(false); + assert.deepEqual(yield* send(), told(false, false)); + yield* setDeviceAccess(true); + assert.deepEqual(yield* send(), told(false, true), "device access turned on later"); + assert.deepEqual(yield* send(), told(false, false)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 75c202b5b57a8..1d98c4523ffef 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -52,7 +52,7 @@ import { hasCursorSkillMention, rewriteCursorSkillMentions, } from "./skills.ts"; -import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; @@ -904,6 +904,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); const events = yield* Queue.unbounded(); const liveAgent = yield* Ref.make(null); + // What the native conversation was told since it started or was last compacted. + const told = yield* Ref.make<{ + readonly agent: string; + readonly orchestration: boolean; + readonly device: boolean; + } | null>(null); const warnedUnsupportedSandbox = yield* Ref.make(false); const activeTurn = yield* Ref.make(null); const planIds = yield* Ref.make(new Map()); @@ -2182,6 +2188,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, mcpServers: Record | undefined, hasDeviceTools: boolean, + delivered: { readonly orchestration: boolean; readonly device: boolean }, ) { const rawText = turnInput.message.text; if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { @@ -2201,7 +2208,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( .map((skill) => skill.name), ); } - const userText = t3OrchestrationPromptForFirstRun({ + const userText = t3OrchestrationPrompt({ prompt: yield* providerMessageTextWithAttachmentPaths({ text: cursorSkillNames === undefined @@ -2211,9 +2218,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( resolveAttachmentPath: host.resolveAttachmentPath, resolveExtractedTextPath: host.resolveExtractedTextPath, }), - runOrdinal: turnInput.runOrdinal, hasT3Mcp: mcpServers !== undefined, hasDeviceTools, + orchestrationDelivered: delivered.orchestration, + deviceInstructionsDelivered: delivered.device, }); const images = yield* Effect.forEach( turnInput.message.attachments.filter(isProviderNativeImageAttachment), @@ -2278,10 +2286,20 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( runtimePolicy: turnInput.runtimePolicy, }); const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const mcpServers = cursorMcpServers(mcpSession); + const hasDeviceTools = + mcpServers !== undefined && mcpSession?.capabilities?.has("device") === true; + const known = yield* Ref.get(told); + // A conversation this process has not sent to was told on its first run. + const delivered = + known?.agent === agent.nativeThreadId + ? known + : { orchestration: turnInput.runOrdinal > 1, device: false }; const message = yield* resolveUserMessage( turnInput, - cursorMcpServers(mcpSession), - mcpSession?.capabilities?.has("device") === true, + mcpServers, + hasDeviceTools, + delivered, ); const pendingUpdates: Array = []; let context: ActiveCursorTurn | null = null; @@ -2303,6 +2321,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }, }) .pipe(Effect.mapError(classifyCursorModelRejection(turnInput.modelSelection))); + const compacted = message === "/compress"; + yield* Ref.set(told, { + agent: agent.nativeThreadId, + orchestration: !compacted && (delivered.orchestration || mcpServers !== undefined), + device: !compacted && hasDeviceTools, + }); const startedAt = yield* DateTime.now; const completed = yield* Deferred.make(); const providerTurnId = idAllocator.derive.providerTurn({ From 7ae08c5662ed868208a90e3c4bff1e11ac305e93 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Sun, 25 Oct 2026 22:30:09 -0400 Subject: [PATCH 08/30] fix(server): device launchers last only as long as their session Each device_open wrote a bound launcher under device/targets that nothing removed, so they piled up across threads and devices, some pinned to runtimes that no longer exist. DeviceService now writes the launchers. Whenever a session ends (close, shutdown, host removal, or the host ending it), it removes every launcher without a live session. On startup it removes the ones a previous run left. The device_open handler now only asks DeviceService for the command. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/AgentDeviceShim.ts | 16 ++ .../src/device/AgentDeviceTarget.test.ts | 31 +++- apps/server/src/device/DeviceService.test.ts | 43 +++++- apps/server/src/device/DeviceService.ts | 141 ++++++++++++++---- apps/server/src/mcp/McpDeviceToolkit.test.ts | 19 +-- .../src/mcp/toolkits/device/handlers.ts | 37 +---- 6 files changed, 206 insertions(+), 81 deletions(-) diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index 63c01e0794d00..110802720e9cd 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -97,3 +97,19 @@ export const bindAgentDeviceLauncher = Effect.fn("AgentDeviceShim.bind")(functio boundArgs: input.targetArgs, }); }); + +/** Removes the bound launchers of agent-device sessions not in `live`. */ +export const pruneAgentDeviceLaunchers = Effect.fn("AgentDeviceShim.prune")(function* (input: { + readonly stateDir: string; + readonly live: ReadonlySet; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(input.stateDir, TARGETS_DIR); + const sessions = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + sessions.filter((session) => !input.live.has(session)), + (session) => fs.remove(path.join(dir, session), { recursive: true, force: true }), + { discard: true }, + ).pipe(Effect.ignore); +}); diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 3aef80728c050..484b38ea0a2a3 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -7,7 +7,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { bindAgentDeviceLauncher, ensureAgentDeviceShim } from "./AgentDeviceShim.ts"; +import { + bindAgentDeviceLauncher, + ensureAgentDeviceShim, + pruneAgentDeviceLaunchers, +} from "./AgentDeviceShim.ts"; import { agentDeviceConfigPath, agentDeviceSession, @@ -159,6 +163,31 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" ), ); + it.effect("prunes the launchers of ended sessions and keeps the shared one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-prune-" }); + const entryPath = path.join(stateDir, "cli.mjs"); + const shared = yield* ensureAgentDeviceShim({ entryPath, stateDir }); + const bind = (session: string) => + bindAgentDeviceLauncher({ entryPath, stateDir, session, targetArgs: [] }); + const kept = yield* bind("t3-open"); + const ended = yield* bind("t3-closed"); + yield* pruneAgentDeviceLaunchers({ stateDir, live: new Set(["t3-open"]) }); + expect(yield* fs.exists(kept)).toBe(true); + expect(yield* fs.exists(path.dirname(ended))).toBe(false); + expect(yield* fs.exists(shared)).toBe(true); + yield* pruneAgentDeviceLaunchers({ stateDir, live: new Set() }); + expect(yield* fs.readDirectory(path.join(stateDir, "device", "targets"))).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "tells the agent to reopen the device when the runtime it was bound to is gone", () => diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index c13179d9d4932..dfca314243470 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -34,6 +34,7 @@ import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; +import { agentDeviceSession } from "./AgentDeviceTarget.ts"; const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); @@ -603,7 +604,7 @@ it.effect("keeps shutdown successful when subsequent discovery fails", () => ); it.effect.each(["shutdown", "close"] as const)( - "%s releases iOS capture so reopening uses a fresh session", + "%s releases iOS capture and the session's launcher so reopening uses a fresh session", (operation) => Effect.gen(function* () { const deviceId = DeviceId.make("11111111-1111-1111-1111-111111111111"); @@ -680,19 +681,49 @@ it.effect.each(["shutdown", "close"] as const)( stopAgent: Effect.void, stop: Effect.void, }; - const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( - Effect.provide(NodeCrypto.layer), - ); + const launchers = new Set(["t3-from-a-previous-run"]); + const service = yield* DeviceService.makeWithHosts( + new Map([[host.id, host]]), + undefined, + undefined, + undefined, + { + bind: ({ session }) => + Effect.sync(() => { + launchers.add(session); + return session; + }), + prune: (live) => + Effect.sync(() => { + for (const session of launchers) if (!live.has(session)) launchers.delete(session); + }), + }, + ).pipe(Effect.provide(NodeCrypto.layer)); + expect(launchers).toEqual(new Set()); const input = { threadId, deviceId, platform: "ios" as const }; - yield* service.open(input); + const other = { ...input, threadId: ThreadId.make("capture-recovery-other") }; + const bind = Effect.fnUntraced(function* (open: typeof input) { + yield* service.open(open); + const session = yield* agentDeviceSession(open.threadId, LOCAL_DEVICE_HOST_ID, deviceId); + yield* service.agentLauncher({ session, targetArgs: [] }); + return session; + }, Effect.provide(NodeCrypto.layer)); + const session = yield* bind(input); + yield* bind(other); expect(capture).toBe(1); + expect(launchers.size).toBe(2); if (operation === "shutdown") yield* service.shutdown(input); else yield* service.close({ threadId, deviceId, shutdown: true }); expect(capture).toBeNull(); expect((yield* service.state).sessions).toEqual([]); - yield* service.open(input); + expect(launchers).toEqual(new Set()); + expect(yield* bind(input)).toBe(session); expect(capture).toBe(2); expect((yield* service.state).sessions).toHaveLength(1); + const otherSession = yield* bind(other); + yield* service.close({ threadId: other.threadId }); + expect(launchers).toEqual(new Set([session])); + expect(otherSession).not.toBe(session); }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d3a84bb310c48..25aae39ff15f9 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -33,6 +33,11 @@ import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; +import { + bindAgentDeviceLauncher, + ensureAgentDeviceShim, + pruneAgentDeviceLaunchers, +} from "./AgentDeviceShim.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -104,7 +109,6 @@ export interface DeviceAgentReadiness extends DeviceReadiness { export class DeviceService extends Context.Service< DeviceService, { - readonly agentCli: Effect.Effect; readonly testHost: ( config: SshDeviceHostConfig, ) => Effect.Effect; @@ -128,6 +132,11 @@ export class DeviceService extends Context.Service< }, DeviceError >; + /** The command agents run for one agent-device session; it is removed when the session ends. */ + readonly agentLauncher: (input: { + readonly session: string; + readonly targetArgs: ReadonlyArray; + }) => Effect.Effect; readonly state: Effect.Effect; readonly subscribe: Effect.Effect, never, Scope.Scope>; readonly configure: ( @@ -165,6 +174,12 @@ interface ServiceState { readonly state: DeviceServiceState; } +export interface AgentDeviceLaunchers { + readonly bind: DeviceService["Service"]["agentLauncher"]; + /** Removes every launcher whose session is not in `live`. */ + readonly prune: (live: ReadonlySet) => Effect.Effect; +} + export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( hosts: ReadonlyMap, testHost: DeviceService["Service"]["testHost"] = (host) => @@ -187,6 +202,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ), installTool?: (tool: "hub" | "agent") => Effect.Effect, + launchers?: AgentDeviceLaunchers, ) { const settings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; @@ -225,11 +241,30 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }, }); + const launcherLock = yield* Semaphore.make(1); + // Reads sessions under the lock, so a launcher bound after a prune began is never removed by it. + const pruneLaunchers = launcherLock.withPermit( + Effect.gen(function* () { + if (!launchers) return; + const { state } = yield* SynchronizedRef.get(stateRef); + const live = yield* Effect.forEach(state.sessions, (session) => + agentDeviceSession(session.threadId, session.hostId, session.deviceId), + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + yield* launchers.prune(new Set(live)); + }), + ); + const publish = (update: (state: DeviceServiceState) => DeviceServiceState) => - SynchronizedRef.updateAndGetEffect(stateRef, ({ state }) => { + SynchronizedRef.modifyEffect(stateRef, ({ state }) => { const next = { ...update(state), revision: state.revision + 1 }; - return PubSub.publish(statePubSub, next).pipe(Effect.as({ state: next })); - }).pipe(Effect.map(({ state }) => state)); + const retired = state.sessions.some((session) => !next.sessions.includes(session)); + return PubSub.publish(statePubSub, next).pipe( + Effect.as([{ next, retired }, { state: next }] as const), + ); + }).pipe( + Effect.tap(({ retired }) => (retired ? pruneLaunchers : Effect.void)), + Effect.map(({ next }) => next), + ); const resolveHost = Effect.fn("DeviceService.resolveHost")(function* ( hostId: DeviceHostId | undefined, @@ -805,6 +840,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)), ); + // Sessions live in memory, so this removes every launcher a previous run left behind. + yield* pruneLaunchers; + return { ...DeviceService.of({ testHost, @@ -831,12 +869,6 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ); }), - agentCli: Effect.fail( - new DeviceHostUnavailableError({ - hostId: LOCAL_DEVICE_HOST_ID, - reason: "Agent CLI installation is unavailable in this device service.", - }), - ), agentTarget: Effect.fn("DeviceService.agentTarget")(function* (input) { const host = yield* resolveHost(input.hostId); const ready = yield* agentReadinessIfSupported(input.hostId); @@ -881,6 +913,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ).pipe(Effect.provideService(Crypto.Crypto, crypto)); return { agentDeviceId, session, args: ["--config", configPath, "--session", session] }; }), + agentLauncher: (input) => + launchers + ? launcherLock.withPermit(launchers.bind(input)) + : Effect.fail( + new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + reason: "Agent launchers are unavailable in this device service.", + }), + ), state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), subscribe: PubSub.subscribe(statePubSub), configure, @@ -956,6 +997,64 @@ export const make = Effect.gen(function* () { }), ), ); + const agentCli = resolveNodeExecutable("Device automation").pipe( + Effect.andThen(ensureAgentDevice(config.baseDir)), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.map((tool) => tool.entryPath), + Effect.mapError((error) => + error._tag === "NodeRuntimeUnavailableError" + ? new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + hostLabel: LOCAL_DEVICE_HOST_LABEL, + reason: nodeRuntimeUnavailableMessage("Device automation"), + cause: error, + }) + : new DeviceOperationError({ + operation: "install agent CLI", + reason: "command_failed", + cause: error, + }), + ), + ); + const launchers: AgentDeviceLaunchers = { + bind: Effect.fn("DeviceService.bindLauncher")( + function* (input) { + const entryPath = yield* agentCli; + // The unbound launcher stays current for agents that still call it with flags. + yield* ensureAgentDeviceShim({ entryPath, stateDir: config.stateDir }); + return yield* bindAgentDeviceLauncher({ entryPath, stateDir: config.stateDir, ...input }); + }, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.catchTags({ + NodeRuntimeUnavailableError: (cause) => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + hostLabel: LOCAL_DEVICE_HOST_LABEL, + reason: nodeRuntimeUnavailableMessage("Device automation"), + cause, + }), + ), + PlatformError: (cause) => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + hostLabel: LOCAL_DEVICE_HOST_LABEL, + reason: "Could not prepare the agent-device launcher.", + cause, + }), + ), + }), + ), + prune: (live) => + pruneAgentDeviceLaunchers({ stateDir: config.stateDir, live }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + }; const probeContext = yield* Effect.context>>(); const localTargetContext = @@ -995,6 +1094,7 @@ export const make = Effect.gen(function* () { }), ), ), + launchers, ); const hostContext = yield* Effect.context>>(); @@ -1136,27 +1236,6 @@ export const make = Effect.gen(function* () { return { ...service, easAccount, - agentCli: resolveNodeExecutable("Device automation").pipe( - Effect.andThen(ensureAgentDevice(config.baseDir)), - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.map((tool) => tool.entryPath), - Effect.mapError((error) => - error._tag === "NodeRuntimeUnavailableError" - ? new DeviceHostUnavailableError({ - hostId: LOCAL_DEVICE_HOST_ID, - hostLabel: LOCAL_DEVICE_HOST_LABEL, - reason: nodeRuntimeUnavailableMessage("Device automation"), - cause: error, - }) - : new DeviceOperationError({ - operation: "install agent CLI", - reason: "command_failed", - cause: error, - }), - ), - ), }; }); diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 5b263998c4d34..160523f256cf2 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -8,8 +8,6 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import { McpSchema, McpServer } from "effect/ai"; @@ -135,7 +133,6 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ ), screenshot: () => Effect.succeed({ device, png }), close: () => Effect.void, - agentCli: Effect.succeed("/cli"), testHost: () => Effect.die("not used"), // As for an EAS device, agent-device knows the device by a different id than T3. agentTarget: () => @@ -144,6 +141,8 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ session: "thread-device", args: ["--config", "/host.json", "--session", "thread-device"], }), + agentLauncher: ({ session, targetArgs }) => + Effect.succeed(`/state/device/targets/${session}/${targetArgs.join("_")}/agent-device`), }); const layerTest = McpHttpServer.layerDeviceToolkit.pipe( @@ -178,15 +177,8 @@ it.effect("registers the device tools and returns the screenshot as image conten `${openedContent.agentDevice.command} snapshot -i`, ); // The launcher selects the device by agent-device's id for it, not T3's. - const launcher = yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* fs.readFileString( - path.join(path.dirname(openedContent.agentDevice.command), "agent-device-launcher.mjs"), - ); - }).pipe(Effect.provide(NodeServices.layer)); - expect(launcher).toContain( - '"--udid","SIM-UDID","--config","/host.json","--session","thread-device"', + expect(openedContent.agentDevice.command).toBe( + "/state/device/targets/thread-device/--platform_ios_--udid_SIM-UDID_--config_/host.json_--session_thread-device/agent-device", ); const shot = yield* server @@ -334,13 +326,14 @@ describe("device targeting across hosts", () => { Effect.sync(() => { closed.push({ hostId: input.hostId, deviceId: input.deviceId }); }), - agentCli: Effect.succeed("/cli"), agentTarget: () => Effect.succeed({ agentDeviceId: "MAC-UDID", session: "thread-device", args: ["--config", "/mac.json", "--session", "thread-device"], }), + agentLauncher: ({ session }) => + Effect.succeed(`/state/device/targets/${session}/agent-device`), }); const call = (name: string, args: Record) => McpServer.McpServer.pipe( diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 12b4e2983bcd6..08515ba878fae 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -10,10 +10,6 @@ import { LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as ServerConfig from "../../../config.ts"; -import { bindAgentDeviceLauncher, ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; -import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; - import * as DeviceService from "../../../device/DeviceService.ts"; import { threadWorkspaceRoot } from "../../../project/threadWorkspaceRoot.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -246,32 +242,13 @@ const handlers = { deviceId: device.id, }); const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); - const config = yield* ServerConfig.ServerConfig; - const entryPath = yield* devices.agentCli; - // The unbound launcher stays current for agents that still call it with flags. - const command = yield* ensureAgentDeviceShim({ entryPath, stateDir: config.stateDir }).pipe( - Effect.andThen( - bindAgentDeviceLauncher({ - entryPath, - stateDir: config.stateDir, - session: agent.session, - targetArgs: [ - ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), - ...agent.args, - ], - }), - ), - Effect.mapError( - (error) => - new DeviceToolUnavailableError({ - reason: - error._tag === "NodeRuntimeUnavailableError" - ? nodeRuntimeUnavailableMessage("Device automation") - : "Could not prepare the agent-device launcher.", - cause: error, - }), - ), - ); + const command = yield* devices.agentLauncher({ + session: agent.session, + targetArgs: [ + ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), + ...agent.args, + ], + }); return { device, agentDevice: { command, targetArgs: [] }, From 3f52d3b694f41d6e6d5feda401c368830c96eaec Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 00:23:16 -0400 Subject: [PATCH 09/30] fix(server): Claude's native subagents get the device instructions Subagents started with Claude's Agent tool begin from their task prompt and never see the session's appended system prompt, so they had no device guidance and drove simulators over ssh. A Claude session that holds the device capability now registers a SubagentStart hook that returns the device block as additionalContext. Sessions without it register no hook. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 68 ++++++++++++++----- .../Adapters/ClaudeAdapterV2.ts | 16 ++++- docs/internals/devices.md | 19 +++--- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 23281614df1c3..d7b3c756ecfd2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -625,23 +625,23 @@ describe("ClaudeAdapterV2 native protocol logging", () => { }); describe("ClaudeAdapterV2 device instructions", () => { + const options = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) => + ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", + }, + nativeThreadId: "native-thread-claude-devices", + resume: false, + cwd: "/workspace", + ...(input.mcp + ? { mcpServers: { "t3-code": { type: "http" as const, url: "http://127.0.0.1/mcp" } } } + : {}), + deviceToolsAvailable: input.deviceToolsAvailable, + environment: {}, + }); const append = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) => - ( - ClaudeAdapterV2.makeClaudeQueryOptions({ - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-5-5", - }, - nativeThreadId: "native-thread-claude-devices", - resume: false, - cwd: "/workspace", - ...(input.mcp - ? { mcpServers: { "t3-code": { type: "http" as const, url: "http://127.0.0.1/mcp" } } } - : {}), - deviceToolsAvailable: input.deviceToolsAvailable, - environment: {}, - }).systemPrompt as { readonly append?: string } - ).append ?? ""; + (options(input).systemPrompt as { readonly append?: string }).append ?? ""; it("gives a session with device access the device block and the ToolSearch line to load it", () => { const text = append({ mcp: true, deviceToolsAvailable: true }); @@ -653,6 +653,42 @@ describe("ClaudeAdapterV2 device instructions", () => { assert.notInclude(append({ mcp: true, deviceToolsAvailable: false }), "## T3 Code devices"); assert.notInclude(append({ mcp: false, deviceToolsAvailable: true }), "## T3 Code devices"); }); + + it("gives native subagents the device block only in a session with device access", async () => { + // Subagents start from their task prompt, not the parent's system prompt. + const subagentContext = (input: { + readonly mcp: boolean; + readonly deviceToolsAvailable: boolean; + }) => + Promise.all( + (options(input).hooks?.SubagentStart ?? []).flatMap((matcher) => + matcher.hooks.map((hook) => + hook( + { + hook_event_name: "SubagentStart", + agent_id: "agent-1", + agent_type: "general-purpose", + session_id: "native-thread-claude-devices", + transcript_path: "/transcript.jsonl", + cwd: "/workspace", + }, + undefined, + { signal: new AbortController().signal }, + ), + ), + ), + ); + assert.deepEqual(await subagentContext({ mcp: true, deviceToolsAvailable: true }), [ + { + hookSpecificOutput: { + hookEventName: "SubagentStart", + additionalContext: T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + }, + }, + ]); + assert.deepEqual(await subagentContext({ mcp: true, deviceToolsAvailable: false }), []); + assert.deepEqual(await subagentContext({ mcp: false, deviceToolsAvailable: true }), []); + }); }); describe("ClaudeAdapterV2 context usage", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b7c0b41f77424..fb204153f27e8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -29,6 +29,7 @@ import { type ForkSessionResult, getSessionMessages, getSubagentMessages, + type HookCallback, query, resolveSettings as resolveClaudeSettings, type Options as ClaudeQueryOptions, @@ -1434,6 +1435,15 @@ const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = ` To wait for a background command or Monitor you started (tests, a build), call \`t3_background_wait\` with its task ID before you end your turn. Do not mark work you leave running, or PRs. T3 MCP tools are deferred: load one with ToolSearch before you call it. `; +// Native subagents start from their task, not this session's system prompt. +const subagentDeviceInstructions: HookCallback = () => + Promise.resolve({ + hookSpecificOutput: { + hookEventName: "SubagentStart", + additionalContext: T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + }, + }); + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -1511,6 +1521,7 @@ export function makeClaudeQueryOptions(input: { ...compactQuerySettings, env: { ...compactQuerySettings?.env, ENABLE_TOOL_SEARCH: input.toolSearchOverride }, } as ClaudeSdkSettings); + const deviceInstructions = input.mcpServers !== undefined && input.deviceToolsAvailable === true; const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, tools: claudeAgentSdkQueryToolsForSdk(selectedTools), @@ -1565,12 +1576,15 @@ export function makeClaudeQueryOptions(input: { ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS + - (input.deviceToolsAvailable === true + (deviceInstructions ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n` : "")), ...launchAppendSystemPrompt, ].join("\n\n"), }, + ...(deviceInstructions + ? { hooks: { SubagentStart: [{ hooks: [subagentDeviceInstructions] }] } } + : {}), ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }), }; const additionalDirectories = [ diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 6bb49018223cc..373bb4fdbc670 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -91,19 +91,20 @@ enabled, the session has the `device` capability, and the machine can run at least one platform. Starting it later from `device_open` would leave the already-running agent without the CLI. -The full command loop is returned from `device_open`, not kept in an +How to drive a device is returned from `device_open`, not kept in an always-loaded prompt or skill: it costs nothing in threads that never open a device and cannot drift from the pinned CLI version. `device_open` returns a -launcher bound to its agent-device session, so agents pass no target flags. +launcher bound to its agent-device session, so agents need no target flags. -Agents choose between these tools and ssh before they open anything, so the -always-on block in +Agents choose between these tools and ssh before they open anything, so +sessions that hold `device` get a short block from [`orchestrationInstructions.ts`](../../packages/provider-core/src/server/orchestrationInstructions.ts) -goes to every provider session that holds `device`. That includes delegated -children, which otherwise see only their task. The block routes simulator and -emulator UI work on local and SSH hosts to the tools, and it names the few verbs -agents used to reach `simctl` for. It leaves ssh for builds, installs, sync, -Metro, port forwards and logs. +through their adapter's instruction channel. It points simulator and emulator UI +work on local and SSH hosts at the tools; `simctl`, `adb` and ssh stay +available for what the tools do not cover. Pi and Muse do not send it yet. +Claude's native subagents never see the session's system prompt, so a +`SubagentStart` hook passes them the block, where the runtime runs hooks and the +subagent's context is not isolated. ## The viewer decodes both vendored protocols From ffba1bdae9b515f8426c08667425e1ec7deeaf98 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 04:55:19 -0400 Subject: [PATCH 10/30] fix(server): a device closed mid-open leaves no agent-device launcher A device_open that was still preparing when its device closed would bind a launcher for the retired session after the close had pruned, leaving it until some later close or a restart. agentLauncher now takes the session's thread, host and device, and under the launcher lock refuses a session that is no longer open. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 16 ++++-- apps/server/src/device/DeviceService.ts | 53 ++++++++++++++----- apps/server/src/mcp/McpDeviceToolkit.test.ts | 7 ++- .../src/mcp/toolkits/device/handlers.ts | 4 +- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index dfca314243470..5b777a1528d16 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -702,11 +702,15 @@ it.effect.each(["shutdown", "close"] as const)( expect(launchers).toEqual(new Set()); const input = { threadId, deviceId, platform: "ios" as const }; const other = { ...input, threadId: ThreadId.make("capture-recovery-other") }; + const target = (thread: ThreadId) => ({ + threadId: thread, + hostId: LOCAL_DEVICE_HOST_ID, + deviceId, + }); const bind = Effect.fnUntraced(function* (open: typeof input) { yield* service.open(open); - const session = yield* agentDeviceSession(open.threadId, LOCAL_DEVICE_HOST_ID, deviceId); - yield* service.agentLauncher({ session, targetArgs: [] }); - return session; + yield* service.agentLauncher({ ...target(open.threadId), targetArgs: [] }); + return yield* agentDeviceSession(open.threadId, LOCAL_DEVICE_HOST_ID, deviceId); }, Effect.provide(NodeCrypto.layer)); const session = yield* bind(input); yield* bind(other); @@ -717,6 +721,12 @@ it.effect.each(["shutdown", "close"] as const)( expect(capture).toBeNull(); expect((yield* service.state).sessions).toEqual([]); expect(launchers).toEqual(new Set()); + // A device_open that was still preparing when the close landed. + const late = yield* Effect.exit( + service.agentLauncher({ ...target(threadId), targetArgs: [] }), + ); + expect(Exit.isFailure(late)).toBe(true); + expect(launchers).toEqual(new Set()); expect(yield* bind(input)).toBe(session); expect(capture).toBe(2); expect((yield* service.state).sessions).toHaveLength(1); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 25aae39ff15f9..678e6da9d96f2 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -134,7 +134,9 @@ export class DeviceService extends Context.Service< >; /** The command agents run for one agent-device session; it is removed when the session ends. */ readonly agentLauncher: (input: { - readonly session: string; + readonly threadId: ThreadId; + readonly hostId: DeviceHostId; + readonly deviceId: DeviceId; readonly targetArgs: ReadonlyArray; }) => Effect.Effect; readonly state: Effect.Effect; @@ -175,7 +177,10 @@ interface ServiceState { } export interface AgentDeviceLaunchers { - readonly bind: DeviceService["Service"]["agentLauncher"]; + readonly bind: (input: { + readonly session: string; + readonly targetArgs: ReadonlyArray; + }) => Effect.Effect; /** Removes every launcher whose session is not in `live`. */ readonly prune: (live: ReadonlySet) => Effect.Effect; } @@ -242,7 +247,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); const launcherLock = yield* Semaphore.make(1); - // Reads sessions under the lock, so a launcher bound after a prune began is never removed by it. + // Binding and pruning read sessions under the lock, so neither acts on a session list the other has outdated. const pruneLaunchers = launcherLock.withPermit( Effect.gen(function* () { if (!launchers) return; @@ -913,15 +918,39 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ).pipe(Effect.provideService(Crypto.Crypto, crypto)); return { agentDeviceId, session, args: ["--config", configPath, "--session", session] }; }), - agentLauncher: (input) => - launchers - ? launcherLock.withPermit(launchers.bind(input)) - : Effect.fail( - new DeviceHostUnavailableError({ - hostId: LOCAL_DEVICE_HOST_ID, - reason: "Agent launchers are unavailable in this device service.", - }), - ), + agentLauncher: Effect.fn("DeviceService.agentLauncher")(function* ({ + targetArgs, + ...target + }) { + if (!launchers) + return yield* new DeviceHostUnavailableError({ + hostId: target.hostId, + reason: "Agent launchers are unavailable in this device service.", + }); + const session = yield* agentDeviceSession( + target.threadId, + target.hostId, + target.deviceId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + return yield* launcherLock.withPermit( + Effect.gen(function* () { + const { state } = yield* SynchronizedRef.get(stateRef); + if ( + !state.sessions.some( + (live) => + live.threadId === target.threadId && + live.hostId === target.hostId && + live.deviceId === target.deviceId, + ) + ) + return yield* new DeviceHostUnavailableError({ + hostId: target.hostId, + reason: "The device closed while it was opening. Retry the operation.", + }); + return yield* launchers.bind({ session, targetArgs }); + }), + ); + }), state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), subscribe: PubSub.subscribe(statePubSub), configure, diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 160523f256cf2..3a652e175324d 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -141,8 +141,8 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ session: "thread-device", args: ["--config", "/host.json", "--session", "thread-device"], }), - agentLauncher: ({ session, targetArgs }) => - Effect.succeed(`/state/device/targets/${session}/${targetArgs.join("_")}/agent-device`), + agentLauncher: ({ targetArgs }) => + Effect.succeed(`/state/device/targets/thread-device/${targetArgs.join("_")}/agent-device`), }); const layerTest = McpHttpServer.layerDeviceToolkit.pipe( @@ -332,8 +332,7 @@ describe("device targeting across hosts", () => { session: "thread-device", args: ["--config", "/mac.json", "--session", "thread-device"], }), - agentLauncher: ({ session }) => - Effect.succeed(`/state/device/targets/${session}/agent-device`), + agentLauncher: () => Effect.succeed("/state/device/targets/thread-device/agent-device"), }); const call = (name: string, args: Record) => McpServer.McpServer.pipe( diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 08515ba878fae..7d1b5ac57c6ad 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -243,7 +243,9 @@ const handlers = { }); const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); const command = yield* devices.agentLauncher({ - session: agent.session, + threadId: scope.thread.threadId, + hostId: device.hostId, + deviceId: device.id, targetArgs: [ ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), ...agent.args, From db1a00660fbc24df768999dda58d371049e78164 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 04:57:30 -0400 Subject: [PATCH 11/30] fix(server): device_open still returns its target flags and session The bound launcher made device_open return empty targetArgs, so test-t3-mobile's pair-client.sh, which needs them, exited with its usage, and `mobile:install --session` had no session name to use. device_open again returns the full targetArgs, which the bound launcher accepts unchanged, plus the agent-device session. quickStart stays flag-free and no longer forbids passing the flags. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 17 +++++++++++++++-- apps/server/src/mcp/toolkits/device/handlers.ts | 13 +++++++------ packages/contracts/src/device.ts | 1 + 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 3a652e175324d..85b14546d343e 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -169,10 +169,23 @@ it.effect("registers the device tools and returns the screenshot as image conten .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); expect(opened.isError).toBe(false); const openedContent = opened.structuredContent as { - agentDevice: { command: string; targetArgs: ReadonlyArray }; + agentDevice: { command: string; targetArgs: ReadonlyArray; session: string }; quickStart: string; }; - expect(openedContent.agentDevice.targetArgs).toEqual([]); + // Callers that pass the target flags, like test-t3-mobile's pair-client.sh, keep working. + expect(openedContent.agentDevice).toMatchObject({ + targetArgs: [ + "--platform", + "ios", + "--udid", + "SIM-UDID", + "--config", + "/host.json", + "--session", + "thread-device", + ], + session: "thread-device", + }); expect(openedContent.quickStart).toContain( `${openedContent.agentDevice.command} snapshot -i`, ); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 7d1b5ac57c6ad..9276586a8b70e 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -43,7 +43,7 @@ export function agentDeviceQuickStart( return [ `The user is watching ${device.name} (${device.version}) in the Device panel.`, target === "" - ? `Drive it with ${executable}. It is bound to this device and session, so pass no --platform, --udid, --serial, --config or --session flags. Use this exact path; login shells may reset PATH.` + ? `Drive it with ${executable}. It is bound to this device and session, so it needs no --platform, --udid, --serial, --config or --session flags. Use this exact path; login shells may reset PATH.` : `Drive it with ${executable}. Use this exact path; login shells may reset PATH. Always pass ${target}.`, "Every app launch, deep link, tap, text entry, alert, setting, screenshot and recording on this device goes through it:", ` ${run("open --relaunch")} # restarts the app; Expo dev clients connect to their Metro, --metro-port picks it`, @@ -242,18 +242,19 @@ const handlers = { deviceId: device.id, }); const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); + const targetArgs = [ + ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), + ...agent.args, + ]; const command = yield* devices.agentLauncher({ threadId: scope.thread.threadId, hostId: device.hostId, deviceId: device.id, - targetArgs: [ - ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), - ...agent.args, - ], + targetArgs, }); return { device, - agentDevice: { command, targetArgs: [] }, + agentDevice: { command, targetArgs, session: agent.session }, quickStart: agentDeviceQuickStart(device, [], command, cloud), }; }).pipe(Effect.mapError(toolError)), diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 78d03db6abe75..dbb90f6d41e58 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -585,6 +585,7 @@ export const DeviceToolOpenResult = Schema.Struct({ agentDevice: Schema.Struct({ command: Schema.String, targetArgs: Schema.Array(Schema.String), + session: Schema.String, }), quickStart: Schema.String, }); From 5f93761e1952467967844b859b10d482c851bfcc Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 14:42:21 -0400 Subject: [PATCH 12/30] fix(server): the device block leaves legitimate ssh work open but keeps device actions on the device tools The block now makes device_list conditional on a known hostId and id, and drops the command sampler that quickStart already carries. It lists the work ssh, simctl and adb stay fine for, with examples agent-device 0.21.31 has no verb for (simctl addmedia, simctl keychain add-root-cert). When a device tool still fails after following its advice once, the agent tells the user and stops, unless the user explicitly asked for ssh. That matches the rule that only the user can send device actions over ssh. Co-Authored-By: Claude Opus 5.5 --- .../provider-core/src/server/orchestrationInstructions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 474fcb2d15138..03dede70b41f9 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -41,11 +41,11 @@ const T3_CODE_DEVICE_TOOLS = ["device_list", "device_open", "device_screenshot", const deviceToolInstructions = (loadTools: string) => `## T3 Code devices When a task needs an iOS Simulator or Android Emulator, on this machine or on an SSH device host such as a remote Mac, use the device tools so the user can watch it in the Device panel.${loadTools} -1. Call \`device_list\`, even if you already know the UDID. Note the device's \`hostId\` and \`id\`. -2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run it, with no device flags, for every app launch, deep link, tap, text entry, alert, setting, screenshot and recording: \`open --relaunch\`, \`open \`, \`snapshot -i\`, \`press @e3\`, \`fill @e5 "text"\`, \`alert accept\`, \`settings appearance dark\`, \`screenshot \`, \`record start|stop\`, \`help \`. +1. Call \`device_list\` unless you already have the device's \`hostId\` and \`id\`. +2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its \`help \` lists the options. 3. Call \`device_screenshot\` to look at the screen. -Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. Use ssh to a device host only for builds, installs, code sync, Metro, port forwards, logs and work that drives no simulator or emulator. These rules override memory notes, docs and delegated tasks that say otherwise; use ssh for device actions only when the user explicitly asks. If a device tool fails, quote its error and follow its advice once; if it still fails, tell the user before you fall back. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; +Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool fails, quote its error and follow its advice once; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; /** Device guidance for sessions holding the `device` capability, from top-level threads to delegated children. */ export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = deviceToolInstructions(""); From e58fb16842f6c672e6dc7170cdc6bd936a947bc5 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 15:12:18 -0400 Subject: [PATCH 13/30] fix(server): device_open binds the session it opened and says when it closed first device_open bound its launcher with the device id it re-read after booting, falling back to the requested id, while the liveness check keys on the session's id. When those differed, as with an Android id that changes on boot, a live session was refused. It now uses the session's host and device ids. A device closed while device_open was preparing it was reported as "Device host is unavailable", which reads like a host failure. agentLauncher now returns None for that case, and device_open says the device was closed and to call device_open again only if it is still needed. A bound launcher is deleted when its session ends, so a later call fails with a shell error. quickStart now says so and points to device_open. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 6 +-- apps/server/src/device/DeviceService.ts | 15 +++--- apps/server/src/mcp/McpDeviceToolkit.test.ts | 49 ++++++++++++++++++- .../src/mcp/toolkits/device/handlers.test.ts | 1 + .../src/mcp/toolkits/device/handlers.ts | 29 +++++++---- 5 files changed, 78 insertions(+), 22 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 5b777a1528d16..6054ba0a58830 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -19,6 +19,7 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -722,10 +723,9 @@ it.effect.each(["shutdown", "close"] as const)( expect((yield* service.state).sessions).toEqual([]); expect(launchers).toEqual(new Set()); // A device_open that was still preparing when the close landed. - const late = yield* Effect.exit( - service.agentLauncher({ ...target(threadId), targetArgs: [] }), + expect(yield* service.agentLauncher({ ...target(threadId), targetArgs: [] })).toEqual( + Option.none(), ); - expect(Exit.isFailure(late)).toBe(true); expect(launchers).toEqual(new Set()); expect(yield* bind(input)).toBe(session); expect(capture).toBe(2); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 678e6da9d96f2..dd42c6dc6eca6 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -49,6 +49,7 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -132,13 +133,16 @@ export class DeviceService extends Context.Service< }, DeviceError >; - /** The command agents run for one agent-device session; it is removed when the session ends. */ + /** + * The command agents run for one agent-device session; it is removed when the session ends. + * None when the session closed before it could be bound. + */ readonly agentLauncher: (input: { readonly threadId: ThreadId; readonly hostId: DeviceHostId; readonly deviceId: DeviceId; readonly targetArgs: ReadonlyArray; - }) => Effect.Effect; + }) => Effect.Effect, DeviceError>; readonly state: Effect.Effect; readonly subscribe: Effect.Effect, never, Scope.Scope>; readonly configure: ( @@ -943,11 +947,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* live.deviceId === target.deviceId, ) ) - return yield* new DeviceHostUnavailableError({ - hostId: target.hostId, - reason: "The device closed while it was opening. Retry the operation.", - }); - return yield* launchers.bind({ session, targetArgs }); + return Option.none(); + return Option.some(yield* launchers.bind({ session, targetArgs })); }), ); }), diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 85b14546d343e..184f2be041e5a 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { McpSchema, McpServer } from "effect/ai"; import * as ServerConfig from "../config.ts"; @@ -142,7 +143,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ args: ["--config", "/host.json", "--session", "thread-device"], }), agentLauncher: ({ targetArgs }) => - Effect.succeed(`/state/device/targets/thread-device/${targetArgs.join("_")}/agent-device`), + Effect.succeedSome(`/state/device/targets/thread-device/${targetArgs.join("_")}/agent-device`), }); const layerTest = McpHttpServer.layerDeviceToolkit.pipe( @@ -314,6 +315,7 @@ describe("device targeting across hosts", () => { McpServer.McpServer >; }) => Effect.Effect, + overrides: Partial = {}, ) => { const opened: Array<{ hostId: string; deviceId: string }> = []; const closed: Array<{ hostId?: string | undefined; deviceId?: string | undefined }> = []; @@ -345,7 +347,8 @@ describe("device targeting across hosts", () => { session: "thread-device", args: ["--config", "/mac.json", "--session", "thread-device"], }), - agentLauncher: () => Effect.succeed("/state/device/targets/thread-device/agent-device"), + agentLauncher: () => Effect.succeedSome("/state/device/targets/thread-device/agent-device"), + ...overrides, }); const call = (name: string, args: Record) => McpServer.McpServer.pipe( @@ -383,6 +386,48 @@ describe("device targeting across hosts", () => { ), ); + it.effect("binds the launcher to the id the device booted with", () => { + const targets: Array = []; + return run( + [], + ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "Pixel_10", hostId: "mac-mini" }); + expect(result.isError).toBe(false); + expect(targets).toEqual(["emulator-5554", "emulator-5554"]); + }), + { + open: (input) => + Effect.succeed(session(input.hostId ?? "local", "emulator-5554", "android")), + agentTarget: (target) => + Effect.sync(() => { + targets.push(target.deviceId); + return { agentDeviceId: target.deviceId, session: "thread-device", args: [] }; + }), + agentLauncher: (target) => + Effect.sync(() => { + targets.push(target.deviceId); + return Option.some("/state/device/targets/thread-device/agent-device"); + }), + }, + ); + }); + + it.effect("says a device closed during device_open was closed, not that its host is down", () => + run( + [], + ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "MAC-UDID" }); + expect(result.isError).toBe(true); + expect(text(result)).toBe( + "The device was closed while device_open was preparing it. Call device_open again only if you still need it.", + ); + }), + { agentLauncher: () => Effect.succeedNone }, + ), + ); + it.effect("refuses a device id that exists on two hosts and names both", () => run([], ({ opened, call }) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 50c9298432277..84c8c3984abeb 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -34,6 +34,7 @@ describe("device tool helpers", () => { expect(examples.join("\n")).not.toMatch(/--(?:platform|udid|serial|config|session)\b/); expect(text).not.toContain("remain available"); expect(text).toContain("Do not use simctl, idb, adb or ssh for those actions."); + expect(text).toContain("If it is missing, call device_open again."); }); it("reads PNG dimensions from the IHDR chunk", () => { diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 9276586a8b70e..307f9f6584e4d 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -10,6 +10,7 @@ import { LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as DeviceService from "../../../device/DeviceService.ts"; import { threadWorkspaceRoot } from "../../../project/threadWorkspaceRoot.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -59,6 +60,11 @@ export function agentDeviceQuickStart( `Prefer snapshot refs over coordinates. Run ${executable} help for flags and ${executable} help workflow for guides.`, "Do not use simctl, idb, adb or ssh for those actions. They stay fine for builds, installs, code sync, Metro, port forwards, logs, test runners and what this command cannot do; come back to it for every UI action and screenshot so the user can watch.", "Each device_open returns its own command; opening another device does not retarget this one.", + ...(target === "" + ? [ + "The command is removed when the device closes or T3 Code restarts. If it is missing, call device_open again.", + ] + : []), platformNotes, ].join("\n"); } @@ -236,22 +242,25 @@ const handlers = { ) ?? target; // Target the opened device: an EAS device and its agent-device daemon // exist only once its session runs, and Android ids change on boot. - const agent = yield* devices.agentTarget({ + const opened = { threadId: scope.thread.threadId, - hostId: device.hostId, - deviceId: device.id, - }); + hostId: session.hostId, + deviceId: session.deviceId, + }; + const agent = yield* devices.agentTarget(opened); const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); const targetArgs = [ ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), ...agent.args, ]; - const command = yield* devices.agentLauncher({ - threadId: scope.thread.threadId, - hostId: device.hostId, - deviceId: device.id, - targetArgs, - }); + const launcher = yield* devices.agentLauncher({ ...opened, targetArgs }); + if (Option.isNone(launcher)) { + return yield* new DeviceToolUnavailableError({ + reason: + "The device was closed while device_open was preparing it. Call device_open again only if you still need it.", + }); + } + const command = launcher.value; return { device, agentDevice: { command, targetArgs, session: agent.session }, From 8e0e3e25e337d7a0091d53dffdaecc206a71f785 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 18:49:24 -0400 Subject: [PATCH 14/30] fix(server): an interrupted device close still removes its launcher DeviceService committed a session's retirement and then waited for the launcher lock to prune. If the caller was cancelled during that wait, for example by an RPC client, the prune was dropped. A bind that was already in progress then left a working launcher for a closed session until the next retirement or a restart. Publishing a state is now uninterruptible, so a committed retirement always prunes. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 27 +++++++++++++++++++- apps/server/src/device/DeviceService.ts | 2 ++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 6054ba0a58830..37c881baa4dde 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -20,6 +20,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -683,6 +684,8 @@ it.effect.each(["shutdown", "close"] as const)( stop: Effect.void, }; const launchers = new Set(["t3-from-a-previous-run"]); + let pauseBind: { entered: Deferred.Deferred; proceed: Deferred.Deferred } | null = + null; const service = yield* DeviceService.makeWithHosts( new Map([[host.id, host]]), undefined, @@ -690,7 +693,11 @@ it.effect.each(["shutdown", "close"] as const)( undefined, { bind: ({ session }) => - Effect.sync(() => { + Effect.gen(function* () { + if (pauseBind) { + yield* Deferred.succeed(pauseBind.entered, undefined); + yield* Deferred.await(pauseBind.proceed); + } launchers.add(session); return session; }), @@ -734,6 +741,24 @@ it.effect.each(["shutdown", "close"] as const)( yield* service.close({ threadId: other.threadId }); expect(launchers).toEqual(new Set([session])); expect(otherSession).not.toBe(session); + // A caller interrupted after retirement committed, while a bind holds the launcher lock. + pauseBind = { entered: yield* Deferred.make(), proceed: yield* Deferred.make() }; + const binding = yield* Effect.forkChild( + service.agentLauncher({ ...target(threadId), targetArgs: [] }), + ); + yield* Deferred.await(pauseBind.entered); + const updates = yield* service.subscribe; + const retiring = yield* Effect.forkChild( + operation === "shutdown" ? service.shutdown(input) : service.close({ threadId }), + ); + expect((yield* PubSub.take(updates)).sessions).toEqual([]); + // list writes through the same state lock, so the retirement has committed. + expect((yield* service.list).sessions).toEqual([]); + yield* Effect.sync(() => retiring.interruptUnsafe()); + yield* Deferred.succeed(pauseBind.proceed, undefined); + yield* Fiber.join(binding); + yield* Fiber.await(retiring); + expect(launchers).toEqual(new Set()); }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index dd42c6dc6eca6..f27be4ea7a7e9 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -263,6 +263,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ); + // Uninterruptible so a committed retirement always prunes, even if its caller is cancelled. const publish = (update: (state: DeviceServiceState) => DeviceServiceState) => SynchronizedRef.modifyEffect(stateRef, ({ state }) => { const next = { ...update(state), revision: state.revision + 1 }; @@ -273,6 +274,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }).pipe( Effect.tap(({ retired }) => (retired ? pruneLaunchers : Effect.void)), Effect.map(({ next }) => next), + Effect.uninterruptible, ); const resolveHost = Effect.fn("DeviceService.resolveHost")(function* ( From d28a0552c8d1b14b8b78bd2a1df4289ee059c2bc Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 18:49:25 -0400 Subject: [PATCH 15/30] fix(server): device_open says how to save full-resolution iOS screenshots agent-device saves iOS simulator screenshots at 1x logical points by default, so agents that used to save `simctl io screenshot` files got about a third of the pixels. quickStart's screenshot example on iOS now names --pixel-density (3 on most iPhones). Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/device/handlers.test.ts | 4 ++++ apps/server/src/mcp/toolkits/device/handlers.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 84c8c3984abeb..f2c5258854fc0 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -31,6 +31,10 @@ describe("device tool helpers", () => { const text = agentDeviceQuickStart(device, [], command); const examples = text.split("\n").filter((line) => line.trimStart().startsWith(command)); expect(examples).toContain(` ${command} snapshot -i # accessibility tree with @eN refs`); + expect(examples.find((line) => line.includes(" screenshot "))).toContain("--pixel-density "); + expect(agentDeviceQuickStart({ ...device, platform: "android" }, [], command)).not.toContain( + "--pixel-density", + ); expect(examples.join("\n")).not.toMatch(/--(?:platform|udid|serial|config|session)\b/); expect(text).not.toContain("remain available"); expect(text).toContain("Do not use simctl, idb, adb or ssh for those actions."); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 307f9f6584e4d..8814373a8f1b0 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -54,7 +54,7 @@ export function agentDeviceQuickStart( ` ${run('fill @e5 "text" --settle')}`, ` ${run("alert accept")}`, ` ${run("settings appearance dark")} # also text-size, permission grant `, - ` ${run("screenshot /tmp/shot.png")} # or call device_screenshot to look`, + ` ${run("screenshot /tmp/shot.png")} # ${device.platform === "ios" ? "iOS saves 1x points; add --pixel-density (3 on most iPhones) for device resolution, " : ""}or call device_screenshot to look`, ` ${run("record start /tmp/run.mp4")} # then record stop`, ` ${run("install ")}`, `Prefer snapshot refs over coordinates. Run ${executable} help for flags and ${executable} help workflow for guides.`, From b59828f46e3b9a4017b708c857ea1297951f2e4f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 19:36:42 -0400 Subject: [PATCH 16/30] fix(evals): the mcp-tool-use eval's Claude prompt keeps the device block Claude's T3 append now includes the device block only when the session holds the device capability. t3SystemAppend built the append without saying so, so the eval's Claude prompt silently lost the block while the eval still offered the device tools. It now passes deviceToolsAvailable whenever the eval's advertised toolkits include the device tools, as the adapter does for a session holding `device`. Co-Authored-By: Claude Opus 5.5 --- evals/mcp-tool-use/run.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index b06f46d373e0f..33edb6465ab4c 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -248,6 +248,8 @@ it("rebuilds the template from this checkout and sends a child T3's task prompt" "You are Claude Code.", `Main prompt.\n - Primary working directory: /home/user/app\n\n${t3SystemAppend("claude-sonnet-5-5")}`, ]); + // The eval offers the device tools, so Claude's append carries the device block. + assert.include(t3SystemAppend("claude-sonnet-5-5"), "## T3 Code devices"); assert.equal(base.environment.cwd, "/home/user/app"); assert.include(system[2], "model named Sonnet 5.5. The exact model ID is claude-sonnet-5-5."); const deferred = system[2]?.split("Load them first:\n")[1]?.split("\n\n")[0]?.split("\n") ?? []; From 610bb2bb5ec7f5367e07aa81933398bfa00c4a4e Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Mon, 26 Oct 2026 23:19:34 -0400 Subject: [PATCH 17/30] fix(server): the device failure rule covers the command device_open returns After a device failure, agents followed the failure rule for device_* tool errors but kept retrying the agent-device command device_open returns, often four or five times in one turn. The rule now names that command too. Co-Authored-By: Claude Opus 5.5 --- packages/provider-core/src/server/orchestrationInstructions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 03dede70b41f9..fa4db555d5c11 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -45,7 +45,7 @@ When a task needs an iOS Simulator or Android Emulator, on this machine or on an 2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its \`help \` lists the options. 3. Call \`device_screenshot\` to look at the screen. -Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool fails, quote its error and follow its advice once; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; +Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; /** Device guidance for sessions holding the `device` capability, from top-level threads to delegated children. */ export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = deviceToolInstructions(""); From b18947241f171c0eb1cb14c00ba06b0e5b7e775f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 05:41:13 -0400 Subject: [PATCH 18/30] refactor(server): DeviceService resolves a device id across hosts The device-id lookup across hosts moves from the MCP device handlers into DeviceService as two operations: locateDevice searches the last listing, and locateSession searches the devices a thread has open. Both apply one rule. A hostId decides, and without one the id has to be on a single host. An id on more than one host fails with DeviceHostAmbiguousError. A hostId that lacks the device fails with DeviceOnOtherHostError, which names the host that has it. An id on no host is None. device_open, device_screenshot and device_close call these operations and map their errors and None to the text agents saw before, byte for byte. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 188 ++++++++++++++++++ apps/server/src/device/DeviceService.ts | 104 +++++++++- apps/server/src/mcp/McpDeviceToolkit.test.ts | 158 ++++++++++++--- .../src/mcp/toolkits/device/handlers.ts | 120 +++++------ 4 files changed, 467 insertions(+), 103 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 37c881baa4dde..57ab04932d21a 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1216,3 +1216,191 @@ it.effect("a failed tool update reports npm's exit code but not its output", () Effect.scoped, ), ); + +describe("locating a device id across hosts", () => { + const threadId = ThreadId.make("thread-locate"); + const thisMachine = { id: LOCAL_DEVICE_HOST_ID, label: "This machine" }; + const macMini = { id: "mac-mini", label: "Mac Mini" }; + const listingHost = ( + host: { readonly id: string; readonly label: string }, + ids: ReadonlyArray, + ): DeviceHost.DeviceHost["Service"] => { + const devices = ids.map((id) => ({ + id, + name: id, + platform: id.startsWith("Pixel") ? ("android" as const) : ("ios" as const), + version: "test", + booted: true, + physical: false, + })); + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + devices: { + list: Effect.succeed({ devices, detail: undefined }), + boot: (device) => Effect.succeed(device.id), + attach: () => Effect.void, + shutdown: () => Effect.void, + screenshot: () => Effect.succeed(new Uint8Array()), + }, + // Opening an emulator checks its host for ffmpeg's H.264 encoder. + run: () => Effect.succeed({ code: 0, stdout: " V....D libx264", stderr: "" }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }; + return { + ...host, + summary: Effect.succeed({ + ...host, + kind: host.id === LOCAL_DEVICE_HOST_ID ? "local" : "ssh", + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: true }, + ], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + }; + // An Android AVD name, like Pixel_10 here, can exist on two hosts. + const withTwoHosts = ( + test: (service: DeviceService.DeviceService["Service"]) => Effect.Effect, + ) => + Effect.gen(function* () { + const service = yield* DeviceService.makeWithHosts( + new Map([ + [thisMachine.id, listingHost(thisMachine, ["Pixel_10"])], + [macMini.id, listingHost(macMini, ["MAC-UDID", "Pixel_10"])], + ]), + ).pipe(Effect.provide(NodeCrypto.layer)); + yield* service.list; + return yield* test(service); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped); + const open = ( + service: DeviceService.DeviceService["Service"], + hostId: string, + deviceId: string, + thread = threadId, + ) => + service.open({ + threadId: thread, + hostId, + deviceId: DeviceId.make(deviceId), + platform: deviceId.startsWith("Pixel") ? "android" : "ios", + }); + + it.effect("finds a device id that exists only on another host", () => + withTwoHosts((service) => + Effect.gen(function* () { + const located = yield* service.locateDevice({ deviceId: DeviceId.make("MAC-UDID") }); + expect(Option.getOrUndefined(located)).toMatchObject({ + hostId: "mac-mini", + id: "MAC-UDID", + }); + }), + ), + ); + + it.effect("refuses a device id that exists on two hosts and names both", () => + withTwoHosts((service) => + Effect.gen(function* () { + const error = yield* service + .locateDevice({ deviceId: DeviceId.make("Pixel_10") }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DeviceHostAmbiguousError", deviceId: "Pixel_10" }); + expect(error.hosts).toHaveLength(2); + expect(error.hosts).toEqual(expect.arrayContaining([thisMachine, macMini])); + }), + ), + ); + + it.effect("keeps an explicit hostId and says which host has the device", () => + withTwoHosts((service) => + Effect.gen(function* () { + const error = yield* service + .locateDevice({ deviceId: DeviceId.make("MAC-UDID"), hostId: LOCAL_DEVICE_HOST_ID }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceOnOtherHostError", + deviceId: "MAC-UDID", + host: thisMachine, + hosts: [macMini], + }); + const onMac = yield* service.locateDevice({ + deviceId: DeviceId.make("Pixel_10"), + hostId: "mac-mini", + }); + expect(Option.getOrUndefined(onMac)).toMatchObject({ hostId: "mac-mini", id: "Pixel_10" }); + }), + ), + ); + + it.effect("finds nothing for a device id that no host lists", () => + withTwoHosts((service) => + Effect.gen(function* () { + const deviceId = DeviceId.make("Pixel_9"); + expect(yield* service.locateDevice({ deviceId })).toEqual(Option.none()); + expect(yield* service.locateDevice({ deviceId, hostId: "mac-mini" })).toEqual( + Option.none(), + ); + expect(yield* service.locateDevice({ deviceId, hostId: "unknown-host" })).toEqual( + Option.none(), + ); + }), + ), + ); + + it.effect("finds an open device by id on whichever host it is on", () => + withTwoHosts((service) => + Effect.gen(function* () { + yield* open(service, "mac-mini", "MAC-UDID"); + const deviceId = DeviceId.make("MAC-UDID"); + expect( + Option.getOrUndefined(yield* service.locateSession({ threadId, deviceId })), + ).toMatchObject({ threadId, hostId: "mac-mini", deviceId }); + const error = yield* service + .locateSession({ threadId, deviceId, hostId: LOCAL_DEVICE_HOST_ID }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceOnOtherHostError", + host: thisMachine, + hosts: [macMini], + }); + }), + ), + ); + + it.effect("refuses an id open on two hosts unless the hostId picks one", () => + withTwoHosts((service) => + Effect.gen(function* () { + yield* open(service, LOCAL_DEVICE_HOST_ID, "Pixel_10"); + yield* open(service, "mac-mini", "Pixel_10"); + const deviceId = DeviceId.make("Pixel_10"); + const error = yield* service.locateSession({ threadId, deviceId }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DeviceHostAmbiguousError", deviceId }); + expect(error.hosts).toEqual(expect.arrayContaining([thisMachine, macMini])); + const onMac = yield* service.locateSession({ threadId, deviceId, hostId: "mac-mini" }); + expect(Option.getOrUndefined(onMac)).toMatchObject({ hostId: "mac-mini", deviceId }); + }), + ), + ); + + it.effect("finds no session for a device this thread has not opened", () => + withTwoHosts((service) => + Effect.gen(function* () { + const deviceId = DeviceId.make("MAC-UDID"); + expect(yield* service.locateSession({ threadId, deviceId })).toEqual(Option.none()); + yield* open(service, "mac-mini", "MAC-UDID", ThreadId.make("thread-other")); + expect(yield* service.locateSession({ threadId, deviceId })).toEqual(Option.none()); + expect( + yield* service.locateSession({ threadId, deviceId, hostId: LOCAL_DEVICE_HOST_ID }), + ).toEqual(Option.none()); + }), + ), + ); +}); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index f27be4ea7a7e9..513be593cc94a 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -5,8 +5,8 @@ import { type DeviceDetail, type DeviceDetailInput, type DeviceError, - type DeviceHostId, - type DeviceId, + DeviceHostId, + DeviceId, DeviceHostUnavailableError, DeviceNotFoundError, DeviceOperationError, @@ -51,6 +51,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -107,6 +108,27 @@ export interface DeviceAgentReadiness extends DeviceReadiness { readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null; } +const DeviceHostName = Schema.Struct({ id: DeviceHostId, label: Schema.optional(Schema.String) }); +const hostName = (host: typeof DeviceHostName.Type) => host.label ?? `host ${host.id}`; + +export class DeviceHostAmbiguousError extends Schema.TaggedError()( + "DeviceHostAmbiguousError", + { deviceId: DeviceId, hosts: Schema.Array(DeviceHostName) }, +) { + override get message(): string { + return `Device ${this.deviceId} is on more than one host: ${this.hosts.map(hostName).join(", ")}.`; + } +} + +export class DeviceOnOtherHostError extends Schema.TaggedError()( + "DeviceOnOtherHostError", + { deviceId: DeviceId, host: DeviceHostName, hosts: Schema.Array(DeviceHostName) }, +) { + override get message(): string { + return `Device ${this.deviceId} is not on ${hostName(this.host)}. It is on ${this.hosts.map(hostName).join(", ")}.`; + } +} + export class DeviceService extends Context.Service< DeviceService, { @@ -164,6 +186,27 @@ export class DeviceService extends Context.Service< readonly hostId?: DeviceHostId | undefined; readonly deviceId: DeviceId; }) => Effect.Effect; + /** + * The device an id names in the last listing. Ids are unique per host, not across hosts (an + * Android AVD name can exist on two), so a hostId decides, and without one the id has to be + * on a single host. + */ + readonly locateDevice: (input: { + readonly hostId?: DeviceHostId | undefined; + readonly deviceId: DeviceId; + }) => Effect.Effect< + Option.Option, + DeviceHostAmbiguousError | DeviceOnOtherHostError + >; + /** As `locateDevice`, among the devices a thread has open. */ + readonly locateSession: (input: { + readonly threadId: ThreadId; + readonly hostId?: DeviceHostId | undefined; + readonly deviceId: DeviceId; + }) => Effect.Effect< + Option.Option, + DeviceHostAmbiguousError | DeviceOnOtherHostError + >; readonly readiness: (hostId?: DeviceHostId) => Effect.Effect; readonly readinessIfSupported: ( hostId?: DeviceHostId, @@ -851,6 +894,61 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)), ); + const locate = ( + state: DeviceServiceState, + input: { readonly hostId?: DeviceHostId | undefined; readonly deviceId: DeviceId }, + matches: ReadonlyArray, + ): Effect.Effect, DeviceHostAmbiguousError | DeviceOnOtherHostError> => { + const named = (id: DeviceHostId) => ({ + id, + label: state.hosts.find((host) => host.id === id)?.label, + }); + const onHost = matches.filter( + (match) => input.hostId === undefined || match.hostId === input.hostId, + ); + const hostIds = [...new Set(onHost.map((match) => match.hostId))]; + if (hostIds.length === 1) return Effect.succeedSome(onHost.at(-1)!); + if (hostIds.length > 1) { + return Effect.fail( + new DeviceHostAmbiguousError({ deviceId: input.deviceId, hosts: hostIds.map(named) }), + ); + } + const elsewhere = [...new Set(matches.map((match) => match.hostId))]; + return input.hostId !== undefined && elsewhere.length > 0 + ? Effect.fail( + new DeviceOnOtherHostError({ + deviceId: input.deviceId, + host: named(input.hostId), + hosts: elsewhere.map(named), + }), + ) + : Effect.succeedNone; + }; + + const locateDevice: DeviceService["Service"]["locateDevice"] = Effect.fn( + "DeviceService.locateDevice", + )(function* (input) { + const { state } = yield* SynchronizedRef.get(stateRef); + return yield* locate( + state, + input, + state.devices.filter((device) => device.id === input.deviceId), + ); + }); + + const locateSession: DeviceService["Service"]["locateSession"] = Effect.fn( + "DeviceService.locateSession", + )(function* (input) { + const { state } = yield* SynchronizedRef.get(stateRef); + return yield* locate( + state, + input, + state.sessions.filter( + (session) => session.threadId === input.threadId && session.deviceId === input.deviceId, + ), + ); + }); + // Sessions live in memory, so this removes every launcher a previous run left behind. yield* pruneLaunchers; @@ -964,6 +1062,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* detail, action, screenshot, + locateDevice, + locateSession, readiness, readinessIfSupported, agentReadinessIfSupported, diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 184f2be041e5a..de0eb0a262098 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -105,6 +105,13 @@ const layerProjectsMock = Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedNone, }); +const openSession = { + threadId, + hostId: "local", + deviceId: DeviceId.make("UDID-1"), + platform: "ios" as const, + openedAt: "2026-09-08T00:00:00.000Z", +}; const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ state: Effect.succeed(state), list: Effect.succeed(state), @@ -118,19 +125,12 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ openedAt: "2026-09-08T00:00:00.000Z", }), - sessionsForThread: (id) => + sessionsForThread: (id) => Effect.succeed(id === threadId ? [openSession] : []), + locateSession: (input) => Effect.succeed( - id === threadId - ? [ - { - threadId, - hostId: "local", - deviceId: DeviceId.make("UDID-1"), - platform: "ios" as const, - openedAt: "2026-09-08T00:00:00.000Z", - }, - ] - : [], + input.threadId === threadId && input.deviceId === openSession.deviceId + ? Option.some(openSession) + : Option.none(), ), screenshot: () => Effect.succeed({ device, png }), close: () => Effect.void, @@ -300,6 +300,18 @@ describe("device targeting across hosts", () => { platform, openedAt: "2026-09-08T00:00:00.000Z", }); + const thisMachine = { id: "local", label: "This machine" }; + const macMini = { id: macHost.id, label: macHost.label }; + // DeviceService's lookup errors; DeviceService.test.ts covers when each arises. + const onTwoHosts = new DeviceService.DeviceHostAmbiguousError({ + deviceId: DeviceId.make("Pixel_10"), + hosts: [thisMachine, macMini], + }); + const onTheMac = new DeviceService.DeviceOnOtherHostError({ + deviceId: DeviceId.make("MAC-UDID"), + host: thisMachine, + hosts: [macMini], + }); const run = ( sessions: ReadonlyArray>, @@ -329,6 +341,26 @@ describe("device targeting across hosts", () => { return session(input.hostId ?? "local", input.deviceId, input.platform); }), sessionsForThread: () => Effect.succeed(sessions), + locateDevice: (input) => + Effect.succeed( + Option.fromUndefinedOr( + multiHostState.devices.find( + (candidate) => + candidate.id === input.deviceId && + (input.hostId === undefined || candidate.hostId === input.hostId), + ), + ), + ), + locateSession: (input) => + Effect.succeed( + Option.fromUndefinedOr( + sessions.find( + (candidate) => + candidate.deviceId === input.deviceId && + (input.hostId === undefined || candidate.hostId === input.hostId), + ), + ), + ), screenshot: (target) => Effect.succeed({ device: @@ -429,23 +461,54 @@ describe("device targeting across hosts", () => { ); it.effect("refuses a device id that exists on two hosts and names both", () => - run([], ({ opened, call }) => - Effect.gen(function* () { - const result = yield* call("device_open", { deviceId: "Pixel_10" }); - expect(result.isError).toBe(true); - expect(text(result)).toContain("hostId local"); - expect(text(result)).toContain("hostId mac-mini"); - expect(opened).toEqual([]); - }), + run( + [], + ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "Pixel_10" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId local"); + expect(text(result)).toContain("hostId mac-mini"); + expect(text(result)).toBe( + "Device Pixel_10 is on more than one host: 'This machine' (hostId local), 'Mac Mini' (hostId mac-mini). Pass hostId.", + ); + expect(opened).toEqual([]); + }), + { locateDevice: () => Effect.fail(onTwoHosts) }, ), ); it.effect("keeps an explicit hostId and says which host has the device", () => + run( + [], + ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "MAC-UDID", hostId: "local" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId mac-mini"); + expect(text(result)).toBe( + "Device MAC-UDID is not on 'This machine' (hostId local). It is on 'Mac Mini' (hostId mac-mini); pass that hostId.", + ); + expect(opened).toEqual([]); + }), + { locateDevice: () => Effect.fail(onTheMac) }, + ), + ); + + it.effect("says no host has a device id, naming the host it was asked on", () => run([], ({ opened, call }) => Effect.gen(function* () { - const result = yield* call("device_open", { deviceId: "MAC-UDID", hostId: "local" }); - expect(result.isError).toBe(true); - expect(text(result)).toContain("hostId mac-mini"); + const missing = (args: Record) => + call("device_open", { deviceId: "Pixel_9", ...args }).pipe(Effect.map(text)); + expect(yield* missing({})).toBe( + "No device Pixel_9 on any host. Call device_list for current ids.", + ); + expect(yield* missing({ hostId: "mac-mini" })).toBe( + "No device Pixel_9 on 'Mac Mini' (hostId mac-mini). Call device_list for current ids.", + ); + expect(yield* missing({ hostId: "unknown-host" })).toBe( + "No device Pixel_9 on hostId unknown-host. Call device_list for current ids.", + ); expect(opened).toEqual([]); }), ), @@ -462,14 +525,32 @@ describe("device targeting across hosts", () => { ); it.effect("says why device_screenshot failed instead of a generic error", () => - run([session("mac-mini", "MAC-UDID", "ios")], ({ call }) => + run( + [session("mac-mini", "MAC-UDID", "ios")], + ({ call }) => + Effect.gen(function* () { + const wrongHost = yield* call("device_screenshot", { + deviceId: "MAC-UDID", + hostId: "local", + }); + expect(wrongHost.isError).toBe(true); + expect(text(wrongHost)).toContain("It is on 'Mac Mini' (hostId mac-mini)"); + expect(text(wrongHost)).toBe( + "Device MAC-UDID is not on 'This machine' (hostId local). It is on 'Mac Mini' (hostId mac-mini); pass that hostId.", + ); + }), + { locateSession: () => Effect.fail(onTheMac) }, + ), + ); + + it.effect("tells an agent to open a device id before screenshotting it", () => + run([], ({ call }) => Effect.gen(function* () { - const wrongHost = yield* call("device_screenshot", { - deviceId: "MAC-UDID", - hostId: "local", - }); - expect(wrongHost.isError).toBe(true); - expect(text(wrongHost)).toContain("It is on 'Mac Mini' (hostId mac-mini)"); + const result = yield* call("device_screenshot", { deviceId: "MAC-UDID" }); + expect(result.isError).toBe(true); + expect(text(result)).toBe( + "Device MAC-UDID is not open in this thread. Call device_open first.", + ); }), ), ); @@ -495,8 +576,25 @@ describe("device targeting across hosts", () => { const result = yield* call("device_close", { deviceId: "Pixel_10" }); expect(result.isError).toBe(true); expect(text(result)).toContain("hostId mac-mini"); + expect(text(result)).toBe( + "Device Pixel_10 is on more than one host: 'This machine' (hostId local), 'Mac Mini' (hostId mac-mini). Pass hostId.", + ); expect(closed).toEqual([]); }), + { locateSession: () => Effect.fail(onTwoHosts) }, + ), + ); + + it.effect("closes an open device on its host and treats an unopened one as closed", () => + run([session("mac-mini", "MAC-UDID", "ios")], ({ closed, call }) => + Effect.gen(function* () { + expect((yield* call("device_close", { deviceId: "MAC-UDID" })).isError).toBe(false); + expect((yield* call("device_close", { deviceId: "Pixel_9" })).isError).toBe(false); + expect(closed).toEqual([ + { hostId: "mac-mini", deviceId: "MAC-UDID" }, + { hostId: undefined, deviceId: "Pixel_9" }, + ]); + }), ), ); }); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 8814373a8f1b0..d59c728be9714 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -4,7 +4,6 @@ import { type DeviceHostSummary, type DeviceId, type DevicePlatform, - type DeviceSession, type DeviceSummary, DeviceToolUnavailableError, LOCAL_DEVICE_HOST_ID, @@ -78,45 +77,32 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev ), ); -const hostName = (hosts: ReadonlyArray, hostId: DeviceHostId) => { - const label = hosts.find((host) => host.id === hostId)?.label; - return label === undefined ? `hostId ${hostId}` : `'${label}' (hostId ${hostId})`; -}; +const hostName = (host: { readonly id: DeviceHostId; readonly label?: string | undefined }) => + host.label === undefined ? `hostId ${host.id}` : `'${host.label}' (hostId ${host.id})`; -/** - * Device ids are unique per host, not across hosts: an Android AVD name can exist on two. - * An explicit hostId decides; without one, the id has to name a single host. - */ -const selectByDeviceId = Effect.fn("handlers.selectByDeviceId")(function* < - T extends { readonly hostId: DeviceHostId }, ->(input: { - readonly deviceId: DeviceId; - readonly matches: ReadonlyArray; - readonly hostId: DeviceHostId | undefined; - readonly hosts: Effect.Effect>; - readonly missing: (hostId: DeviceHostId | undefined) => string; -}): Effect.fn.Return { - const onHost = input.matches.filter( - (match) => input.hostId === undefined || match.hostId === input.hostId, +const mapLocateErrors = ( + effect: Effect.Effect< + A, + DeviceService.DeviceHostAmbiguousError | DeviceService.DeviceOnOtherHostError, + R + >, +) => + effect.pipe( + Effect.catchTags({ + DeviceHostAmbiguousError: (error) => + Effect.fail( + new DeviceToolUnavailableError({ + reason: `Device ${error.deviceId} is on more than one host: ${error.hosts.map(hostName).join(", ")}. Pass hostId.`, + }), + ), + DeviceOnOtherHostError: (error) => + Effect.fail( + new DeviceToolUnavailableError({ + reason: `Device ${error.deviceId} is not on ${hostName(error.host)}. It is on ${error.hosts.map(hostName).join(", ")}; pass that hostId.`, + }), + ), + }), ); - const hostIds = [...new Set(onHost.map((match) => match.hostId))]; - if (hostIds.length === 1) return onHost.at(-1)!; - const hosts = yield* input.hosts; - const names = (ids: ReadonlyArray) => - ids.map((id) => hostName(hosts, id)).join(", "); - if (hostIds.length > 1) { - return yield* new DeviceToolUnavailableError({ - reason: `Device ${input.deviceId} is on more than one host: ${names(hostIds)}. Pass hostId.`, - }); - } - const elsewhere = [...new Set(input.matches.map((match) => match.hostId))]; - return yield* new DeviceToolUnavailableError({ - reason: - input.hostId !== undefined && elsewhere.length > 0 - ? `Device ${input.deviceId} is not on ${hostName(hosts, input.hostId)}. It is on ${names(elsewhere)}; pass that hostId.` - : input.missing(input.hostId), - }); -}); const pickDevice = Effect.fn("handlers.pickDevice")(function* ( state: { @@ -128,16 +114,14 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( readonly platform?: DevicePlatform | undefined; readonly hostId?: DeviceHostId | undefined; }, -): Effect.fn.Return { +): Effect.fn.Return { if (input.deviceId !== undefined) { - const deviceId = input.deviceId; - return yield* selectByDeviceId({ - deviceId, - matches: state.devices.filter((device) => device.id === deviceId), - hostId: input.hostId, - hosts: Effect.succeed(state.hosts), - missing: (hostId) => - `No device ${deviceId} on ${hostId === undefined ? "any host" : hostName(state.hosts, hostId)}. Call device_list for current ids.`, + const { deviceId, hostId } = input; + const devices = yield* DeviceService.DeviceService; + const located = yield* devices.locateDevice({ deviceId, hostId }).pipe(mapLocateErrors); + if (Option.isSome(located)) return located.value; + return yield* new DeviceToolUnavailableError({ + reason: `No device ${deviceId} on ${hostId === undefined ? "any host" : hostName(state.hosts.find((host) => host.id === hostId) ?? { id: hostId })}. Call device_list for current ids.`, }); } const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; @@ -163,20 +147,6 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( return candidates.find((device) => device.booted) ?? candidates[0]!; }); -const selectOpenSession = ( - devices: DeviceService.DeviceService["Service"], - sessions: ReadonlyArray, - deviceId: DeviceId, - hostId: DeviceHostId | undefined, -) => - selectByDeviceId({ - deviceId, - matches: sessions.filter((session) => session.deviceId === deviceId), - hostId, - hosts: devices.state.pipe(Effect.map((state) => state.hosts)), - missing: () => `Device ${deviceId} is not open in this thread. Call device_open first.`, - }); - const toolError = (error: DeviceError | DeviceToolUnavailableError) => error; const handlers = { @@ -272,16 +242,23 @@ const handlers = { Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; - const sessions = yield* devices.sessionsForThread(scope.thread.threadId); + const threadId = scope.thread.threadId; const target = input.deviceId === undefined - ? sessions.findLast( + ? (yield* devices.sessionsForThread(threadId)).findLast( (session) => input.hostId === undefined || session.hostId === input.hostId, ) - : yield* selectOpenSession(devices, sessions, input.deviceId, input.hostId); + : Option.getOrUndefined( + yield* devices + .locateSession({ threadId, deviceId: input.deviceId, hostId: input.hostId }) + .pipe(mapLocateErrors), + ); if (!target) { return yield* new DeviceToolUnavailableError({ - reason: "No device is open in this thread. Call device_open first.", + reason: + input.deviceId === undefined + ? "No device is open in this thread. Call device_open first." + : `Device ${input.deviceId} is not open in this thread. Call device_open first.`, }); } const shot = yield* devices.screenshot(target); @@ -300,12 +277,13 @@ const handlers = { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; const deviceId = input.deviceId; - const sessions = - deviceId === undefined ? [] : yield* devices.sessionsForThread(scope.thread.threadId); - const hostId = - deviceId === undefined || !sessions.some((session) => session.deviceId === deviceId) - ? input.hostId - : (yield* selectOpenSession(devices, sessions, deviceId, input.hostId)).hostId; + const open = + deviceId === undefined + ? Option.none() + : yield* devices + .locateSession({ threadId: scope.thread.threadId, deviceId, hostId: input.hostId }) + .pipe(mapLocateErrors); + const hostId = Option.isSome(open) ? open.value.hostId : input.hostId; yield* devices.close({ threadId: scope.thread.threadId, ...(hostId === undefined ? {} : { hostId }), From b416ae256a1f22a18080a3b61734c0589802d8cd Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 09:35:54 -0400 Subject: [PATCH 19/30] fix(server): device guidance starts the returned command with open On a real Mac, the command device_open returns could not take a screenshot or start a recording until `open` had run. It always passes `--session`, and agent-device answers SESSION_NOT_FOUND until `open` creates that session. The device block and quickStart offered screenshot and recording as first steps. The quickStart's `record start` also uses app scope, which needs an app opened in the session. Step 2 of the device block and the quickStart now start with `open `, or `open` alone for the home screen. The quickStart records with `--scope device`, which works after either. device_open still does not run `open` itself, because on iOS that can start an XCTest runner build. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/device/handlers.test.ts | 12 ++++++++++++ apps/server/src/mcp/toolkits/device/handlers.ts | 6 ++++-- .../src/server/orchestrationInstructions.test.ts | 7 +++++++ .../src/server/orchestrationInstructions.ts | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index f2c5258854fc0..8fb14438a75db 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -41,6 +41,18 @@ describe("device tool helpers", () => { expect(text).toContain("If it is missing, call device_open again."); }); + it("starts with open, which creates the session, and records the device screen", () => { + const command = "/state/device/targets/t3-a/agent-device"; + const text = agentDeviceQuickStart(device, [], command); + expect(text).toContain( + "Start with open , or open alone for the home screen; the other commands need the session it starts:", + ); + // App-scoped recording needs an app opened in the session; device scope works after a bare open. + expect(text).toContain( + ` ${command} record start --scope device /tmp/run.mp4 # then record stop`, + ); + }); + it("reads PNG dimensions from the IHDR chunk", () => { const png = new Uint8Array(24); new DataView(png.buffer).setUint32(0, 0x89504e47); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index d59c728be9714..f9d1a2a2b0b55 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -45,7 +45,9 @@ export function agentDeviceQuickStart( target === "" ? `Drive it with ${executable}. It is bound to this device and session, so it needs no --platform, --udid, --serial, --config or --session flags. Use this exact path; login shells may reset PATH.` : `Drive it with ${executable}. Use this exact path; login shells may reset PATH. Always pass ${target}.`, - "Every app launch, deep link, tap, text entry, alert, setting, screenshot and recording on this device goes through it:", + // agent-device creates the session on the agent's first open. device_open does not run open + // itself: on iOS that can start a minutes-long XCTest runner build. + "Every app launch, deep link, tap, text entry, alert, setting, screenshot and recording on this device goes through it. Start with open , or open alone for the home screen; the other commands need the session it starts:", ` ${run("open --relaunch")} # restarts the app; Expo dev clients connect to their Metro, --metro-port picks it`, ` ${run("open ")}`, ` ${run("snapshot -i")} # accessibility tree with @eN refs`, @@ -54,7 +56,7 @@ export function agentDeviceQuickStart( ` ${run("alert accept")}`, ` ${run("settings appearance dark")} # also text-size, permission grant `, ` ${run("screenshot /tmp/shot.png")} # ${device.platform === "ios" ? "iOS saves 1x points; add --pixel-density (3 on most iPhones) for device resolution, " : ""}or call device_screenshot to look`, - ` ${run("record start /tmp/run.mp4")} # then record stop`, + ` ${run("record start --scope device /tmp/run.mp4")} # then record stop`, ` ${run("install ")}`, `Prefer snapshot refs over coordinates. Run ${executable} help for flags and ${executable} help workflow for guides.`, "Do not use simctl, idb, adb or ssh for those actions. They stay fine for builds, installs, code sync, Metro, port forwards, logs, test runners and what this command cannot do; come back to it for every UI action and screenshot so the user can watch.", diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts index d1476e4d86613..e68c6ae2dd836 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.test.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -24,6 +24,13 @@ describe("T3 device instructions", () => { ); }); + it("start the returned command with open, which creates the agent-device session the rest need", () => { + assert.include( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + "Run that command, starting with `open ` (or `open` for the home screen), for every", + ); + }); + it("reach OpenCode and Cursor only when the session holds device access", () => { assert.include(t3OrchestrationSystemPrompt(true, true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); assert.notInclude(t3OrchestrationSystemPrompt(true, false), "## T3 Code devices"); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index fa4db555d5c11..899be3be13333 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -42,7 +42,7 @@ const deviceToolInstructions = (loadTools: string) => `## T3 Code devices When a task needs an iOS Simulator or Android Emulator, on this machine or on an SSH device host such as a remote Mac, use the device tools so the user can watch it in the Device panel.${loadTools} 1. Call \`device_list\` unless you already have the device's \`hostId\` and \`id\`. -2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its \`help \` lists the options. +2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command, starting with \`open \` (or \`open\` for the home screen), for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its \`help \` lists the options. 3. Call \`device_screenshot\` to look at the screen. Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; From 3e4168acb3944f32f2682a19ef1eaf68e16cf33a Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 10:43:06 -0400 Subject: [PATCH 20/30] fix(server): device guidance opens the app before snapshots, screenshots and input The previous change told agents to start every use of the command device_open returns with `open`, and offered `open` alone "for the home screen". Against agent-device 0.21.31 that was too broad in one way and wrong in another. Install, apps, settings appearance and device-scope recording need no session, and device_screenshot needs no CLI session at all, so the blanket rule steered agents that only needed a look into `open`, which on iOS starts the XCTest runner build. A bare `open` starts a session without an app and does not go home. On an iOS Simulator, snapshot then fails with "iOS snapshot requires an active app session", which leaves no @refs to tap. The device block now says the command's snapshots, screenshots and input need `open ` first. The quickStart says to run open (apps lists them) before snapshots, CLI screenshots or input, and to use device_screenshot just to look. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/device/handlers.test.ts | 7 ++++--- apps/server/src/mcp/toolkits/device/handlers.ts | 7 ++++--- .../src/server/orchestrationInstructions.test.ts | 4 ++-- .../provider-core/src/server/orchestrationInstructions.ts | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 8fb14438a75db..666a244ed50f6 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -41,13 +41,14 @@ describe("device tool helpers", () => { expect(text).toContain("If it is missing, call device_open again."); }); - it("starts with open, which creates the session, and records the device screen", () => { + it("opens the app before snapshots, CLI screenshots or input, looks with device_screenshot, and records the device screen", () => { const command = "/state/device/targets/t3-a/agent-device"; const text = agentDeviceQuickStart(device, [], command); + // device_open starts no agent-device session, and an iOS snapshot needs one with an app. expect(text).toContain( - "Start with open , or open alone for the home screen; the other commands need the session it starts:", + "Run open (apps lists them) before snapshots, CLI screenshots or input; it starts the session they need. Use device_screenshot just to look.", ); - // App-scoped recording needs an app opened in the session; device scope works after a bare open. + // App-scoped recording needs an app opened in the session; device scope does not. expect(text).toContain( ` ${command} record start --scope device /tmp/run.mp4 # then record stop`, ); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index f9d1a2a2b0b55..b0ddb3ae6eafb 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -45,9 +45,10 @@ export function agentDeviceQuickStart( target === "" ? `Drive it with ${executable}. It is bound to this device and session, so it needs no --platform, --udid, --serial, --config or --session flags. Use this exact path; login shells may reset PATH.` : `Drive it with ${executable}. Use this exact path; login shells may reset PATH. Always pass ${target}.`, - // agent-device creates the session on the agent's first open. device_open does not run open - // itself: on iOS that can start a minutes-long XCTest runner build. - "Every app launch, deep link, tap, text entry, alert, setting, screenshot and recording on this device goes through it. Start with open , or open alone for the home screen; the other commands need the session it starts:", + // Screenshots and input need the session agent-device's open starts, and an iOS snapshot also + // needs its app. device_open does not run open itself: on iOS that can start a minutes-long + // XCTest runner build. + "Every app launch, deep link, tap, text entry, alert, setting, screenshot and recording on this device goes through it. Run open (apps lists them) before snapshots, CLI screenshots or input; it starts the session they need. Use device_screenshot just to look.", ` ${run("open --relaunch")} # restarts the app; Expo dev clients connect to their Metro, --metro-port picks it`, ` ${run("open ")}`, ` ${run("snapshot -i")} # accessibility tree with @eN refs`, diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts index e68c6ae2dd836..5af38404fea91 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.test.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -24,10 +24,10 @@ describe("T3 device instructions", () => { ); }); - it("start the returned command with open, which creates the agent-device session the rest need", () => { + it("open the app before the returned command's snapshots, screenshots and input", () => { assert.include( T3_CODE_DEVICE_TOOL_INSTRUCTIONS, - "Run that command, starting with `open ` (or `open` for the home screen), for every", + "its snapshots, screenshots and input need `open ` first,", ); }); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 899be3be13333..50628a356c61d 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -42,7 +42,7 @@ const deviceToolInstructions = (loadTools: string) => `## T3 Code devices When a task needs an iOS Simulator or Android Emulator, on this machine or on an SSH device host such as a remote Mac, use the device tools so the user can watch it in the Device panel.${loadTools} 1. Call \`device_list\` unless you already have the device's \`hostId\` and \`id\`. -2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command, starting with \`open \` (or \`open\` for the home screen), for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its \`help \` lists the options. +2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its snapshots, screenshots and input need \`open \` first, and \`help \` lists the options. 3. Call \`device_screenshot\` to look at the screen. Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; From b91fa6d8ba0e98cf3ac1ebabe54e8caeb4529b5e Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 11:03:15 -0400 Subject: [PATCH 21/30] fix(server): the device-access error says where the user turns access on With agent device access off, the agent gets no device instructions and can't see Settings, so it could only tell the user access was off. The error now names Settings > Integrations > Devices, as in t3dotgg's 2026-10-26 fleet investigation (finding F8, recommendation R7: show the access error separately and say how to fix it). Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 7 +++++++ apps/server/src/mcp/toolkits/device/handlers.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index de0eb0a262098..a2d3c31e81e34 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -214,6 +214,13 @@ it.effect("registers the device tools and returns the screenshot as image conten .callTool({ name: "device_list", arguments: {} }) .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client)); expect(denied.isError).toBe(true); + // The agent can't see Settings, so the error says where the user turns access on. + expect(denied.content).toEqual([ + { + type: "text", + text: "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices.", + }, + ]); }), ).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index b0ddb3ae6eafb..caa4665b22126 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -75,7 +75,8 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev Effect.mapError( () => new DeviceToolUnavailableError({ - reason: "Agent device access is turned off for this environment.", + reason: + "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices.", }), ), ); From c4852f5b2edd8f6c308873cf4c7da1768ef526b0 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 12:01:38 -0400 Subject: [PATCH 22/30] fix(server): the device-access error says access applies when the agent session next starts T3 grants the device capability when a provider session starts or a thread first attaches, and a running session keeps the capabilities it started with. The access error named the setting but not that, so agents told the user to turn it on and ask again in the same thread, where the retry gets the same error. Like T3's browser-access error, it now says the setting applies when the agent session next starts, such as in a new thread. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 5 +++-- apps/server/src/mcp/toolkits/device/handlers.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index a2d3c31e81e34..6341d23b6bfc3 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -214,11 +214,12 @@ it.effect("registers the device tools and returns the screenshot as image conten .callTool({ name: "device_list", arguments: {} }) .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client)); expect(denied.isError).toBe(true); - // The agent can't see Settings, so the error says where the user turns access on. + // The agent can't see Settings, and a running session keeps the capabilities it started with, + // so the error says where the user turns access on and when it applies. expect(denied.content).toEqual([ { type: "text", - text: "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices.", + text: "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", }, ]); }), diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index caa4665b22126..4036f083ca910 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -76,7 +76,7 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev () => new DeviceToolUnavailableError({ reason: - "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices.", + "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", }), ), ); From b5b76e5edf1d817b32d898569954eef2da30edd3 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 12:01:39 -0400 Subject: [PATCH 23/30] fix(server): the device failure rule never reuses or closes another session When another session holds a device, agent-device fails with DEVICE_IN_USE and advises rerunning with that session's --session, or closing it first. T3 has no cross-thread device gate and the bound command accepts an explicit --session, so "follow its advice once" sent agents into another thread's session, as in t3dotgg's 2026-10-26 fleet device investigation (finding F3). The rule still follows an error's advice once, but never to reuse or close another session. Co-Authored-By: Claude Opus 5.5 --- .../src/server/orchestrationInstructions.test.ts | 8 ++++++++ .../provider-core/src/server/orchestrationInstructions.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts index 5af38404fea91..c8dd21cc02634 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.test.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -31,6 +31,14 @@ describe("T3 device instructions", () => { ); }); + it("keep the failure rule from taking over or closing a session another thread holds", () => { + // agent-device's DEVICE_IN_USE advice is to rerun with the owner's --session or close it first. + assert.include( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + "follow its advice once, but never reuse or close another session; if it still fails,", + ); + }); + it("reach OpenCode and Cursor only when the session holds device access", () => { assert.include(t3OrchestrationSystemPrompt(true, true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); assert.notInclude(t3OrchestrationSystemPrompt(true, false), "## T3 Code devices"); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 50628a356c61d..428aec2513d32 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -45,7 +45,7 @@ When a task needs an iOS Simulator or Android Emulator, on this machine or on an 2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its snapshots, screenshots and input need \`open \` first, and \`help \` lists the options. 3. Call \`device_screenshot\` to look at the screen. -Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; +Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once, but never reuse or close another session; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; /** Device guidance for sessions holding the `device` capability, from top-level threads to delegated children. */ export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = deviceToolInstructions(""); From be619d51eac69ae29fbafabae5404cd97d1b63f2 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 20:37:03 -0400 Subject: [PATCH 24/30] fix(server): the bound agent-device command handles commands that refuse its flags, and says why it cannot connect Two failures from a real Codex thread driving the Mac mini's simulator: - `cdp target list` failed with "unknown option '--platform'": the launcher added every bound flag to every command. agent-device 0.21.31 refuses some of them for cdp, device status and connection, so those commands now get only the flags they accept. connect, disconnect, auth and daemon would replace or stop the connection T3 manages, so the launcher refuses them. - Inside Codex's network sandbox the command could not reach T3's device connection on 127.0.0.1 and agent-device said "Remote daemon is unavailable", the same error as a dropped tunnel. The launcher now connects first and says which it is: a sandbox (connect EPERM/EACCES, rerun with network access) or a connection that is down (call device_open again). Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/AgentDeviceShim.ts | 39 ++++- .../src/device/AgentDeviceTarget.test.ts | 137 +++++++++++++++++- docs/internals/devices.md | 5 + 3 files changed, 172 insertions(+), 9 deletions(-) diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index 110802720e9cd..5cea32bd4d617 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -12,21 +12,31 @@ const STALE_RUNTIME = const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; -// Help needs no device, and agent-device reads --config even for help, so help gets no bound flags. -// Arguments after `--` are text, so only those before it are read as flags. +// Help gets no bound flags (agent-device reads --config even for help); only args before `--` are flags. +// agent-device 0.21.31 refuses some bound flags per command, and connect/disconnect/auth/daemon would +// replace T3's connection. It reports a sandboxed shell and a dropped tunnel alike, so check first. const launcherSource = (node: string, entryPath: string, boundArgs: ReadonlyArray) => `import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import { connect } from "node:net"; const entry = ${JSON.stringify(entryPath)}; const bound = ${JSON.stringify(boundArgs)}; +const refused = new Map([["cdp", ["--platform", "--udid", "--config", "--session"]], ["device", ["--session"]], ["connection", ["--platform", "--udid"]]]); +const managed = ["connect", "disconnect", "auth", "daemon"]; const args = process.argv.slice(2); const end = args.indexOf("--"); const options = end < 0 ? args : args.slice(0, end); +const name = options.find(arg => !arg.startsWith("-")) ?? ""; const informational = args.length === 0 || ["help", "--help", "-h", "--version", "version"].includes(args[0]) || options.includes("--help") || options.includes("-h"); -if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!options.some(arg => arg === bound[index] || arg.startsWith(bound[index] + "="))) options.push(bound[index], bound[index + 1]); +if (!informational && managed.includes(name)) { + console.error("T3 Code manages this command's connection to the device, so \`" + name + "\` is not available through it."); + process.exit(1); +} +const skip = refused.get(name) ?? []; +if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!skip.includes(bound[index]) && !options.some(arg => arg === bound[index] || arg.startsWith(bound[index] + "="))) options.push(bound[index], bound[index + 1]); const command = end < 0 ? options : [...options, ...args.slice(end)]; const hasValue = flag => { const index = options.indexOf(flag); return options.some(arg => arg.startsWith(flag + "=") && arg.length > flag.length + 1) || (index >= 0 && !!options[index + 1] && !options[index + 1].startsWith("--")); }; -if (!informational && !(hasValue("--config") && hasValue("--session"))) { +if (!informational && !skip.includes("--session") && !(hasValue("--config") && hasValue("--session"))) { console.error("Call device_open first and run the agent-device command it returns."); process.exit(1); } @@ -34,6 +44,25 @@ if (!existsSync(entry)) { console.error(${JSON.stringify(STALE_RUNTIME)}); process.exit(1); } +const valueOf = flag => { const inline = options.find(arg => arg.startsWith(flag + "=")); if (inline) return inline.slice(flag.length + 1); const index = options.indexOf(flag); return index >= 0 ? options[index + 1] : undefined; }; +const endpoint = () => { try { const url = new URL(JSON.parse(readFileSync(valueOf("--config"), "utf8")).daemonBaseUrl); return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.port ? url : undefined; } catch { return undefined; } }; +const local = informational || refused.has(name) ? undefined : endpoint(); +if (local) { + const failure = await new Promise(resolve => { + const socket = connect({ host: local.hostname.replace(/^\\[|\\]$/g, ""), port: Number(local.port) }); + socket.setTimeout(3000, () => { socket.destroy(); resolve("ETIMEDOUT"); }); + socket.once("connect", () => { socket.destroy(); resolve(undefined); }); + socket.once("error", error => resolve(error.code ?? "ERROR")); + }); + if (failure === "EPERM" || failure === "EACCES") { + console.error("This shell cannot reach T3 Code's device connection at " + local.host + " (connect " + failure + "): a network sandbox is blocking localhost. Run this command again with network access, outside the sandbox."); + process.exit(1); + } + if (failure) { + console.error("T3 Code's connection to this device's host (" + local.host + ") is not running. Call device_open again to reconnect."); + process.exit(1); + } +} const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" }; delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 484b38ea0a2a3..a651f91ff3839 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -2,6 +2,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; +import * as NodeNet from "node:net"; import * as NodeUtil from "node:util"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -20,6 +21,18 @@ import { const exec = NodeUtil.promisify(NodeChildProcess.execFile); +/** A loopback port that accepts connections until the scope closes, like T3's device connection. */ +const listeningPort = Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer((socket) => socket.destroy()); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => Effect.promise(() => new Promise((resolve) => server.close(() => resolve()))), +).pipe(Effect.map((server) => (server.address() as NodeNet.AddressInfo).port)); + describe("host-bound agent commands", () => { it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "runs both desktop hops as Node with a stripped environment, preserving arguments and exit code", @@ -163,6 +176,121 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" ), ); + it.effect("passes each command only the bound flags agent-device accepts for it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-commands-" }); + const entryPath = path.join(dir, "cli.mjs"); + yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));"); + const command = yield* bindAgentDeviceLauncher({ + entryPath, + stateDir: dir, + session: "t3-a", + targetArgs: [ + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + "--session", + "t3-a", + ], + }); + const launch = (args: ReadonlyArray) => + exec(process.execPath, [ + path.join(path.dirname(command), "agent-device-launcher.mjs"), + ...args, + ]).then((result) => JSON.parse(result.stdout)); + // agent-device 0.21.31 rejects these flags with "unknown option" or "not supported for command". + const cdp = ["cdp", "target", "list", "--url", "http://127.0.0.1:8081"]; + expect(yield* Effect.promise(() => launch(cdp))).toEqual(cdp); + expect(yield* Effect.promise(() => launch(["device", "status"]))).toEqual([ + "device", + "status", + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + ]); + expect(yield* Effect.promise(() => launch(["connection"]))).toEqual([ + "connection", + "--config", + "/h.json", + "--session", + "t3-a", + ]); + for (const managed of [["connect"], ["disconnect"], ["auth", "status"], ["daemon", "stop"]]) { + yield* Effect.promise(() => + expect(launch(managed)).rejects.toThrow( + `T3 Code manages this command's connection to the device, so \`${managed[0]}\` is not available through it.`, + ), + ); + } + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + + it.live("stops before agent-device when T3's device connection is down", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-endpoint-" }); + const entryPath = path.join(dir, "cli.mjs"); + yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));"); + const configPath = path.join(dir, "host.json"); + // A port that was just free: nothing listens on it, so a connect is refused. + const port = yield* Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as NodeNet.AddressInfo; + server.close(() => resolve(address.port)); + }); + }), + ); + yield* writeAgentDeviceConfig(configPath, { + baseUrl: `http://127.0.0.1:${port}`, + token: "secret", + }); + const command = yield* bindAgentDeviceLauncher({ + entryPath, + stateDir: dir, + session: "t3-a", + targetArgs: ["--config", configPath, "--session", "t3-a"], + }); + const launch = (args: ReadonlyArray) => + exec(process.execPath, [ + path.join(path.dirname(command), "agent-device-launcher.mjs"), + ...args, + ]); + yield* Effect.promise(() => + expect(launch(["screenshot", "/tmp/shot.png"])).rejects.toMatchObject({ + code: 1, + stdout: "", + stderr: `T3 Code's connection to this device's host (127.0.0.1:${port}) is not running. Call device_open again to reconnect.\n`, + }), + ); + // cdp talks to Metro, not to T3's connection, so it still runs. + expect( + JSON.parse((yield* Effect.promise(() => launch(["cdp", "target", "list"]))).stdout), + ).toEqual(["cdp", "target", "list"]); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + it.effect("prunes the launchers of ended sessions and keeps the shared one", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -241,9 +369,10 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, const files = yield* Effect.forEach(["mini", "android"], (host) => agentDeviceConfigPath(dir, host, path), ); + const ports = [yield* listeningPort, yield* listeningPort, yield* listeningPort]; for (const [index, file] of files.entries()) yield* writeAgentDeviceConfig(file, { - baseUrl: `http://127.0.0.1:${1000 + index}`, + baseUrl: `http://127.0.0.1:${ports[index]}`, token: `token-${index}`, }); const invoke = (file: string) => @@ -260,12 +389,12 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, { env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } }, ).then((result) => JSON.parse(result.stdout)); expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([ - { daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" }, - { daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" }, + { daemonBaseUrl: `http://127.0.0.1:${ports[0]}`, daemonAuthToken: "token-0" }, + { daemonBaseUrl: `http://127.0.0.1:${ports[1]}`, daemonAuthToken: "token-1" }, ]); const second = yield* fs.readFileString(files[1]!); yield* writeAgentDeviceConfig(files[0]!, { - baseUrl: "http://127.0.0.1:2000", + baseUrl: `http://127.0.0.1:${ports[2]}`, token: "new", }); expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new"); diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 373bb4fdbc670..d5360e9a74814 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -95,6 +95,11 @@ How to drive a device is returned from `device_open`, not kept in an always-loaded prompt or skill: it costs nothing in threads that never open a device and cannot drift from the pinned CLI version. `device_open` returns a launcher bound to its agent-device session, so agents need no target flags. +The launcher leaves out the flags a command refuses (`cdp` talks to Metro, not +to the device), refuses the commands that would replace T3's connection, and +checks that connection first: agent-device reports a sandboxed shell and a +dropped tunnel alike as an unreachable daemon, so the launcher names which one it +is. Agents choose between these tools and ssh before they open anything, so sessions that hold `device` get a short block from From fe52096eca507c2518593a53d93a70108b4d4d6c Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 20:37:04 -0400 Subject: [PATCH 25/30] fix(server): ending a device session ends its agent-device session Closing a device (device_close, the Device panel, a shutdown) removed T3's session and launcher but never told agent-device, so its claim on the simulator stayed until the host's daemon exited, and every other agent-device client on that Mac got DEVICE_IN_USE for it. Retiring a session now runs the launcher's `close` before the launcher goes, under the launcher lock so a reopen of the same device cannot be closed by it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 18 ++++++++ apps/server/src/device/DeviceService.ts | 48 ++++++++++++++++++-- docs/internals/devices.md | 3 +- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 57ab04932d21a..e2b19ce195476 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -684,6 +684,7 @@ it.effect.each(["shutdown", "close"] as const)( stop: Effect.void, }; const launchers = new Set(["t3-from-a-previous-run"]); + const released: Array<[string, boolean]> = []; let pauseBind: { entered: Deferred.Deferred; proceed: Deferred.Deferred } | null = null; const service = yield* DeviceService.makeWithHosts( @@ -705,6 +706,11 @@ it.effect.each(["shutdown", "close"] as const)( Effect.sync(() => { for (const session of launchers) if (!live.has(session)) launchers.delete(session); }), + // Records whether the launcher still existed, since its close runs through it. + release: (launcher) => + Effect.sync(() => { + released.push([launcher, launchers.has(launcher)]); + }), }, ).pipe(Effect.provide(NodeCrypto.layer)); expect(launchers).toEqual(new Set()); @@ -729,6 +735,17 @@ it.effect.each(["shutdown", "close"] as const)( expect(capture).toBeNull(); expect((yield* service.state).sessions).toEqual([]); expect(launchers).toEqual(new Set()); + // Retiring a session ends its agent-device session too, which releases the device claim. + // Powering the device off retires the other thread's session on it as well. + const firstOther = yield* agentDeviceSession( + other.threadId, + LOCAL_DEVICE_HOST_ID, + deviceId, + ).pipe(Effect.provide(NodeCrypto.layer)); + expect(new Set(released.map(([launcher]) => launcher))).toEqual( + new Set([session, firstOther]), + ); + expect(released.every(([, existed]) => existed)).toBe(true); // A device_open that was still preparing when the close landed. expect(yield* service.agentLauncher({ ...target(threadId), targetArgs: [] })).toEqual( Option.none(), @@ -740,6 +757,7 @@ it.effect.each(["shutdown", "close"] as const)( const otherSession = yield* bind(other); yield* service.close({ threadId: other.threadId }); expect(launchers).toEqual(new Set([session])); + expect(released.slice(2)).toEqual([[otherSession, true]]); expect(otherSession).not.toBe(session); // A caller interrupted after retirement committed, while a bind holds the launcher lock. pauseBind = { entered: yield* Deferred.make(), proceed: yield* Deferred.make() }; diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 513be593cc94a..1d62f13fc2acd 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -230,6 +230,8 @@ export interface AgentDeviceLaunchers { }) => Effect.Effect; /** Removes every launcher whose session is not in `live`. */ readonly prune: (live: ReadonlySet) => Effect.Effect; + /** Ends the agent-device session behind a launcher, which releases its claim on the device. */ + readonly release?: (launcher: string) => Effect.Effect; } export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( @@ -294,15 +296,29 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); const launcherLock = yield* Semaphore.make(1); + /** Launchers bound since startup, by agent-device session. */ + const boundLaunchers = new Map(); // Binding and pruning read sessions under the lock, so neither acts on a session list the other has outdated. + // A retired session's agent-device session ends before its launcher goes, and a reopen of the same + // device waits for the lock, so the close cannot end the reopened session. const pruneLaunchers = launcherLock.withPermit( Effect.gen(function* () { if (!launchers) return; const { state } = yield* SynchronizedRef.get(stateRef); - const live = yield* Effect.forEach(state.sessions, (session) => - agentDeviceSession(session.threadId, session.hostId, session.deviceId), - ).pipe(Effect.provideService(Crypto.Crypto, crypto)); - yield* launchers.prune(new Set(live)); + const live = new Set( + yield* Effect.forEach(state.sessions, (session) => + agentDeviceSession(session.threadId, session.hostId, session.deviceId), + ).pipe(Effect.provideService(Crypto.Crypto, crypto)), + ); + const retired = [...boundLaunchers].filter(([session]) => !live.has(session)); + for (const [session] of retired) boundLaunchers.delete(session); + const release = launchers.release; + if (release) + yield* Effect.forEach(retired, ([, launcher]) => release(launcher), { + concurrency: "unbounded", + discard: true, + }); + yield* launchers.prune(live); }), ); @@ -1048,7 +1064,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ) ) return Option.none(); - return Option.some(yield* launchers.bind({ session, targetArgs })); + const launcher = yield* launchers.bind({ session, targetArgs }); + boundLaunchers.set(session, launcher); + return Option.some(launcher); }), ); }), @@ -1186,6 +1204,26 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), + // A session that never ran `open` has nothing to end, and an unreachable host fails fast in + // the launcher's connection check; either way the launcher still goes. + release: (launcher) => + resolveNodeExecutable("Device automation").pipe( + Effect.flatMap((node) => + runner.run({ + command: node, + args: [path.join(path.dirname(launcher), "agent-device-launcher.mjs"), "close"], + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + timeout: "15 seconds", + timeoutBehavior: "timedOutResult", + }), + ), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.tapError((cause) => + Effect.logWarning("Could not end the agent-device session", { launcher, cause }), + ), + Effect.ignore, + ), }; const probeContext = yield* Effect.context>>(); diff --git a/docs/internals/devices.md b/docs/internals/devices.md index d5360e9a74814..e9033340f73c9 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -99,7 +99,8 @@ The launcher leaves out the flags a command refuses (`cdp` talks to Metro, not to the device), refuses the commands that would replace T3's connection, and checks that connection first: agent-device reports a sandboxed shell and a dropped tunnel alike as an unreachable daemon, so the launcher names which one it -is. +is. Ending a session (`device_close`, the Device panel, a shutdown) also closes +its agent-device session, which releases the CLI's claim on the device. Agents choose between these tools and ssh before they open anything, so sessions that hold `device` get a short block from From fe52e45a001dd9d20a40050c1394fb7597ed4379 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 20:50:05 -0400 Subject: [PATCH 26/30] fix(server): the bound agent-device command finds the command past leading flag values, and logs closes that fail Council review (Sol) found that the launcher took the first token without a dash as the command, so `--config /x.json device status` read the path as the command, added the `--session` that `device` refuses, and skipped the managed-command refusal for `--config /x.json auth status`. It now skips the values of agent-device 0.21.31's value-taking flags and reads the command after a leading `--`. A close through the launcher that exits non-zero or times out was discarded silently, though either can leave the device claimed. It is now classified (closed, no session, failed, timed out) and the last two are logged. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/AgentDeviceShim.ts | 161 +++++++++++++++++- .../src/device/AgentDeviceTarget.test.ts | 45 ++++- apps/server/src/device/DeviceService.ts | 15 +- 3 files changed, 216 insertions(+), 5 deletions(-) diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index 5cea32bd4d617..61ef964ac0e4f 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -12,6 +12,142 @@ const STALE_RUNTIME = const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; +/** agent-device 0.21.31's flags that take a value, so the launcher can find the command past them. */ +const VALUE_FLAGS: ReadonlyArray = [ + "--activity", + "--all", + "--android-device-allowlist", + "--app", + "--appium-version", + "--artifact", + "--artifacts-dir", + "--aws-app-arn", + "--aws-device-arn", + "--aws-interaction-mode", + "--aws-project-arn", + "--aws-region", + "-b", + "--baseline", + "--bearer-token", + "--bundle-url", + "--button", + "--config", + "--count", + "--crop-on", + "--custom-network", + "-d", + "--daemon-auth-token", + "--daemon-base-url", + "--daemon-server-mode", + "--daemon-transport", + "--delay-ms", + "--depth", + "--device", + "--device-orientation", + "--dsym", + "--duration-ms", + "--fps", + "--from", + "--geo-location", + "--github-actions-artifact", + "--header", + "--hold-ms", + "--host", + "--in-app", + "--include", + "--interval-ms", + "--ios-simulator-device-set", + "--ios-xctest-derived-data-path", + "--ios-xctest-env-dir", + "--ios-xctestrun-file", + "--jitter-px", + "--keyframes", + "--kind", + "--language", + "--launch-args", + "--launch-console", + "--launch-url", + "--lease-backend", + "--lease-id", + "--level", + "--listen-host", + "--locale", + "--max-passes", + "--max-steps", + "--metro-host", + "--metro-kind", + "--metro-port", + "--network-profile", + "--on-error", + "--os-version", + "--out", + "--pattern", + "--pause-ms", + "--pixel-density", + "--pixels", + "--plan-digest", + "--platform", + "--pointer-count", + "--port", + "--probe-timeout-ms", + "--project-root", + "--provider", + "--provider-app", + "--provider-appium-version", + "--provider-build", + "--provider-custom-network", + "--provider-device-orientation", + "--provider-device-type", + "--provider-geo-location", + "--provider-language", + "--provider-locale", + "--provider-network-profile", + "--provider-os-version", + "--provider-project", + "--provider-session", + "--provider-session-name", + "--provider-timezone", + "--proxy-base-url", + "--public-base-url", + "--quality", + "--record-as", + "--remote-config", + "--reporter", + "--report-junit", + "--retention-ms", + "--retries", + "--run-id", + "--runtime-file", + "-s", + "--scale", + "--scope", + "--search-path", + "--serial", + "--session", + "--session-isolation", + "--session-lock", + "--settle-quiet", + "--shard-all", + "--shard-split", + "--startup-timeout-ms", + "--state-dir", + "--status-host", + "--steps", + "--steps-file", + "--surface", + "--system", + "--target", + "--target-app", + "--template", + "--tenant", + "--threshold", + "--timeout", + "--timezone", + "--udid", + "--until", + "--wait", +]; + // Help gets no bound flags (agent-device reads --config even for help); only args before `--` are flags. // agent-device 0.21.31 refuses some bound flags per command, and connect/disconnect/auth/daemon would // replace T3's connection. It reports a sandboxed shell and a dropped tunnel alike, so check first. @@ -26,7 +162,14 @@ const managed = ["connect", "disconnect", "auth", "daemon"]; const args = process.argv.slice(2); const end = args.indexOf("--"); const options = end < 0 ? args : args.slice(0, end); -const name = options.find(arg => !arg.startsWith("-")) ?? ""; +const valued = new Set(${JSON.stringify(VALUE_FLAGS)}); +let name = ""; +for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--") { name = args[index + 1] ?? ""; break; } + if (!arg.startsWith("-")) { name = arg; break; } + if (!arg.includes("=") && valued.has(arg)) index++; +} const informational = args.length === 0 || ["help", "--help", "-h", "--version", "version"].includes(args[0]) || options.includes("--help") || options.includes("-h"); if (!informational && managed.includes(name)) { console.error("T3 Code manages this command's connection to the device, so \`" + name + "\` is not available through it."); @@ -142,3 +285,19 @@ export const pruneAgentDeviceLaunchers = Effect.fn("AgentDeviceShim.prune")(func { discard: true }, ).pipe(Effect.ignore); }); + +export type AgentDeviceCloseOutcome = "closed" | "no-session" | "failed" | "timed-out"; + +/** What a launcher's `close` did; only a failure or timeout can leave the device claimed. */ +export const agentDeviceCloseOutcome = (result: { + readonly code: number | null; + readonly stderr: string; + readonly timedOut: boolean; +}): AgentDeviceCloseOutcome => + result.timedOut + ? "timed-out" + : result.code === 0 + ? "closed" + : result.stderr.includes("SESSION_NOT_FOUND") + ? "no-session" + : "failed"; diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index a651f91ff3839..c7ede076d2f9a 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { + agentDeviceCloseOutcome, bindAgentDeviceLauncher, ensureAgentDeviceShim, pruneAgentDeviceLaunchers, @@ -223,10 +224,30 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" "--session", "t3-a", ]); - for (const managed of [["connect"], ["disconnect"], ["auth", "status"], ["daemon", "stop"]]) { + // The command is found past leading flags and their values, in either form. + for (const config of [["--config", "/o.json"], ["--config=/o.json"]]) { + expect(yield* Effect.promise(() => launch([...config, "device", "status"]))).toEqual([ + ...config, + "device", + "status", + "--platform", + "ios", + "--udid", + "UDID-1", + ]); + } + expect(yield* Effect.promise(() => launch(["--json", ...cdp]))).toEqual(["--json", ...cdp]); + for (const managed of [ + ["connect"], + ["disconnect"], + ["auth", "status"], + ["daemon", "stop"], + ["--config", "/o.json", "auth", "status"], + ["--", "connect"], + ]) { yield* Effect.promise(() => expect(launch(managed)).rejects.toThrow( - `T3 Code manages this command's connection to the device, so \`${managed[0]}\` is not available through it.`, + `T3 Code manages this command's connection to the device, so \`${managed.find((arg) => ["connect", "disconnect", "auth", "daemon"].includes(arg))}\` is not available through it.`, ), ); } @@ -238,6 +259,26 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" ), ); + it("tells a released session from a close that failed or timed out", () => { + const result = (code: number | null, stderr = "", timedOut = false) => ({ + stdout: "", + stderr, + code, + timedOut, + }); + expect(agentDeviceCloseOutcome(result(0))).toBe("closed"); + // A session that never ran `open` has nothing to release. + expect( + agentDeviceCloseOutcome( + result(1, "Error (SESSION_NOT_FOUND): No active session. Run open first."), + ), + ).toBe("no-session"); + expect( + agentDeviceCloseOutcome(result(1, "Error (COMMAND_FAILED): Remote daemon is unavailable")), + ).toBe("failed"); + expect(agentDeviceCloseOutcome(result(null, "", true))).toBe("timed-out"); + }); + it.live("stops before agent-device when T3's device connection is down", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 1d62f13fc2acd..7017b6d7ed56b 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -34,6 +34,7 @@ import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/s import * as Path from "effect/Path"; import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import { + agentDeviceCloseOutcome, bindAgentDeviceLauncher, ensureAgentDeviceShim, pruneAgentDeviceLaunchers, @@ -1204,8 +1205,8 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), - // A session that never ran `open` has nothing to end, and an unreachable host fails fast in - // the launcher's connection check; either way the launcher still goes. + // Best effort: an unreachable host fails fast in the launcher's connection check, and the + // launcher goes either way. Only a failed or timed-out close can leave the device claimed. release: (launcher) => resolveNodeExecutable("Device automation").pipe( Effect.flatMap((node) => @@ -1219,6 +1220,16 @@ export const make = Effect.gen(function* () { ), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.flatMap((result) => { + const outcome = agentDeviceCloseOutcome(result); + return outcome === "failed" || outcome === "timed-out" + ? Effect.logWarning("Could not end the agent-device session", { + launcher, + outcome, + stderr: result.stderr.slice(-500), + }) + : Effect.void; + }), Effect.tapError((cause) => Effect.logWarning("Could not end the agent-device session", { launcher, cause }), ), From 3cb69694b368b1396913d838a13e576c39884622 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 20:52:45 -0400 Subject: [PATCH 27/30] fix(server): device connection errors cover network-namespace sandboxes, and powering off releases first Council review (Opus): - A sandbox that gives the shell its own network namespace (bubblewrap --unshare-net) refuses 127.0.0.1 with ECONNREFUSED, like a dropped tunnel, so "is not running, call device_open again" sent agents into a loop. The message now says to retry device_open and, if that succeeds and the error repeats, to run outside the sandbox. - Powering a device off from the Device panel retired its sessions after the shutdown, when agent-device can no longer close the session's app and keeps the claim. Sessions now retire first. - `device status` through the bound command read this machine's claims, not the device host's, so it is refused with a pointer to the DEVICE_IN_USE error. - The docs say that closing a session quits the app it opened. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/AgentDeviceShim.ts | 12 +++++--- .../src/device/AgentDeviceTarget.test.ts | 29 +++++++------------ apps/server/src/device/DeviceService.test.ts | 2 ++ apps/server/src/device/DeviceService.ts | 4 ++- docs/internals/devices.md | 2 +- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index 61ef964ac0e4f..4517a868e329a 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -149,15 +149,15 @@ const VALUE_FLAGS: ReadonlyArray = [ ]; // Help gets no bound flags (agent-device reads --config even for help); only args before `--` are flags. -// agent-device 0.21.31 refuses some bound flags per command, and connect/disconnect/auth/daemon would -// replace T3's connection. It reports a sandboxed shell and a dropped tunnel alike, so check first. +// agent-device 0.21.31 refuses some bound flags per command; connect/disconnect/auth/daemon would +// replace T3's connection, and device status reads this machine's claims. It reports a sandboxed shell and a dropped tunnel alike, so check first. const launcherSource = (node: string, entryPath: string, boundArgs: ReadonlyArray) => `import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { connect } from "node:net"; const entry = ${JSON.stringify(entryPath)}; const bound = ${JSON.stringify(boundArgs)}; -const refused = new Map([["cdp", ["--platform", "--udid", "--config", "--session"]], ["device", ["--session"]], ["connection", ["--platform", "--udid"]]]); +const refused = new Map([["cdp", ["--platform", "--udid", "--config", "--session"]], ["connection", ["--platform", "--udid"]]]); const managed = ["connect", "disconnect", "auth", "daemon"]; const args = process.argv.slice(2); const end = args.indexOf("--"); @@ -175,6 +175,10 @@ if (!informational && managed.includes(name)) { console.error("T3 Code manages this command's connection to the device, so \`" + name + "\` is not available through it."); process.exit(1); } +if (!informational && name === "device") { + console.error("\`device status\` reads this machine's device claims, not those on the device's host, so it is not available through this command. Quote the DEVICE_IN_USE error instead."); + process.exit(1); +} const skip = refused.get(name) ?? []; if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!skip.includes(bound[index]) && !options.some(arg => arg === bound[index] || arg.startsWith(bound[index] + "="))) options.push(bound[index], bound[index + 1]); const command = end < 0 ? options : [...options, ...args.slice(end)]; @@ -202,7 +206,7 @@ if (local) { process.exit(1); } if (failure) { - console.error("T3 Code's connection to this device's host (" + local.host + ") is not running. Call device_open again to reconnect."); + console.error("T3 Code's device connection at " + local.host + " is not reachable from this shell (connect " + failure + "). Call device_open again to reconnect; if that succeeds and this repeats, this shell runs in a network sandbox, so run the command with network access, outside the sandbox."); process.exit(1); } } diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index c7ede076d2f9a..54a6185814038 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -207,16 +207,6 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" // agent-device 0.21.31 rejects these flags with "unknown option" or "not supported for command". const cdp = ["cdp", "target", "list", "--url", "http://127.0.0.1:8081"]; expect(yield* Effect.promise(() => launch(cdp))).toEqual(cdp); - expect(yield* Effect.promise(() => launch(["device", "status"]))).toEqual([ - "device", - "status", - "--platform", - "ios", - "--udid", - "UDID-1", - "--config", - "/h.json", - ]); expect(yield* Effect.promise(() => launch(["connection"]))).toEqual([ "connection", "--config", @@ -226,15 +216,18 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" ]); // The command is found past leading flags and their values, in either form. for (const config of [["--config", "/o.json"], ["--config=/o.json"]]) { - expect(yield* Effect.promise(() => launch([...config, "device", "status"]))).toEqual([ + expect(yield* Effect.promise(() => launch([...config, "connection"]))).toEqual([ ...config, - "device", - "status", - "--platform", - "ios", - "--udid", - "UDID-1", + "connection", + "--session", + "t3-a", ]); + // device status reads claims on the machine it runs on, which is not the device's host. + yield* Effect.promise(() => + expect(launch([...config, "device", "status"])).rejects.toThrow( + "`device status` reads this machine's device claims, not those on the device's host", + ), + ); } expect(yield* Effect.promise(() => launch(["--json", ...cdp]))).toEqual(["--json", ...cdp]); for (const managed of [ @@ -317,7 +310,7 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" expect(launch(["screenshot", "/tmp/shot.png"])).rejects.toMatchObject({ code: 1, stdout: "", - stderr: `T3 Code's connection to this device's host (127.0.0.1:${port}) is not running. Call device_open again to reconnect.\n`, + stderr: `T3 Code's device connection at 127.0.0.1:${port} is not reachable from this shell (connect ECONNREFUSED). Call device_open again to reconnect; if that succeeds and this repeats, this shell runs in a network sandbox, so run the command with network access, outside the sandbox.\n`, }), ); // cdp talks to Metro, not to T3's connection, so it still runs. diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index e2b19ce195476..a561223e8e36d 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -752,6 +752,8 @@ it.effect.each(["shutdown", "close"] as const)( ); expect(launchers).toEqual(new Set()); expect(yield* bind(input)).toBe(session); + // Reopening the same device ends nothing. + expect(released).toHaveLength(2); expect(capture).toBe(2); expect((yield* service.state).sessions).toHaveLength(1); const otherSession = yield* bind(other); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 7017b6d7ed56b..2d7f6c989e8ae 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -851,16 +851,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }, ); + // Sessions retire before the power-off, while their agent-device sessions can still close the app; + // agent-device keeps a claim whose app close fails. const shutdown: DeviceService["Service"]["shutdown"] = Effect.fn("DeviceService.shutdown")( function* (input) { const host = yield* resolveHost(input.hostId); - yield* shutdownDevice(host.id, input.deviceId, input.platform); yield* publish((current) => ({ ...current, sessions: current.sessions.filter( (session) => !(session.hostId === host.id && session.deviceId === input.deviceId), ), })); + yield* shutdownDevice(host.id, input.deviceId, input.platform); }, ); diff --git a/docs/internals/devices.md b/docs/internals/devices.md index e9033340f73c9..c50cb504a84e7 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -100,7 +100,7 @@ to the device), refuses the commands that would replace T3's connection, and checks that connection first: agent-device reports a sandboxed shell and a dropped tunnel alike as an unreachable daemon, so the launcher names which one it is. Ending a session (`device_close`, the Device panel, a shutdown) also closes -its agent-device session, which releases the CLI's claim on the device. +its agent-device session, which quits the app that session opened and releases the CLI's claim on the device. Agents choose between these tools and ssh before they open anything, so sessions that hold `device` get a short block from From e1a0e5a54a6be3076e47d6360a4bcf02d52a733a Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 21:01:51 -0400 Subject: [PATCH 28/30] fix(server): a device_close that powers off releases every session on the device first Council review (Opus, round 15) asked for a test that sessions release before a power-off. It showed that device_close with shutdown released only the calling thread's session in time: another thread's session on the same simulator retired inside the power-off, after agent-device could no longer close its app, so that claim could stay. A shutdown close now retires every session on the device before powering it off, and the test records whether the device was on at each release. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 5 +++++ apps/server/src/device/DeviceService.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index a561223e8e36d..1261094338901 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -685,6 +685,8 @@ it.effect.each(["shutdown", "close"] as const)( }; const launchers = new Set(["t3-from-a-previous-run"]); const released: Array<[string, boolean]> = []; + // Whether the device was still on when each session was released. + const releasedWhileBooted: Array = []; let pauseBind: { entered: Deferred.Deferred; proceed: Deferred.Deferred } | null = null; const service = yield* DeviceService.makeWithHosts( @@ -710,6 +712,7 @@ it.effect.each(["shutdown", "close"] as const)( release: (launcher) => Effect.sync(() => { released.push([launcher, launchers.has(launcher)]); + releasedWhileBooted.push(booted); }), }, ).pipe(Effect.provide(NodeCrypto.layer)); @@ -746,6 +749,8 @@ it.effect.each(["shutdown", "close"] as const)( new Set([session, firstOther]), ); expect(released.every(([, existed]) => existed)).toBe(true); + // agent-device can close the session's app only while the device is on. + expect(releasedWhileBooted).toEqual([true, true]); // A device_open that was still preparing when the close landed. expect(yield* service.agentLauncher({ ...target(threadId), targetArgs: [] })).toEqual( Option.none(), diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 2d7f6c989e8ae..6d7ede5e845fb 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -837,9 +837,16 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (input.deviceId === undefined || session.deviceId === input.deviceId), ); if (closing.length === 0) return; + // Powering off ends every session on the device, so all of them retire, and release, while it is on. + const ending = (session: DeviceSession) => + closing.includes(session) || + (input.shutdown === true && + closing.some( + (closed) => closed.hostId === session.hostId && closed.deviceId === session.deviceId, + )); yield* publish((current) => ({ ...current, - sessions: current.sessions.filter((session) => !closing.includes(session)), + sessions: current.sessions.filter((session) => !ending(session)), })); if (input.shutdown) { yield* Effect.forEach( From 621de28de7441fecd4d5c1deff596c9b877d47b7 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Tue, 27 Oct 2026 21:43:36 -0400 Subject: [PATCH 29/30] fix(server): the bound command's connection check uses --daemon-base-url when it is given Review feedback (Macroscope): the launcher always checked the endpoint in the --config file, so a command with a reachable --daemon-base-url was refused when the configured endpoint was down. agent-device sends to --daemon-base-url over the config (checked against 0.21.31: /health and /rpc go to the flag's URL), so the check now does the same. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/AgentDeviceShim.ts | 5 +++-- .../server/src/device/AgentDeviceTarget.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index 4517a868e329a..44861501e1e4b 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -150,7 +150,8 @@ const VALUE_FLAGS: ReadonlyArray = [ // Help gets no bound flags (agent-device reads --config even for help); only args before `--` are flags. // agent-device 0.21.31 refuses some bound flags per command; connect/disconnect/auth/daemon would -// replace T3's connection, and device status reads this machine's claims. It reports a sandboxed shell and a dropped tunnel alike, so check first. +// replace T3's connection; device status reads this machine's claims. It reports a sandboxed shell and a +// dropped tunnel alike, so check first, at --daemon-base-url if given, as agent-device prefers it. const launcherSource = (node: string, entryPath: string, boundArgs: ReadonlyArray) => `import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; @@ -192,7 +193,7 @@ if (!existsSync(entry)) { process.exit(1); } const valueOf = flag => { const inline = options.find(arg => arg.startsWith(flag + "=")); if (inline) return inline.slice(flag.length + 1); const index = options.indexOf(flag); return index >= 0 ? options[index + 1] : undefined; }; -const endpoint = () => { try { const url = new URL(JSON.parse(readFileSync(valueOf("--config"), "utf8")).daemonBaseUrl); return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.port ? url : undefined; } catch { return undefined; } }; +const endpoint = () => { try { const url = new URL(valueOf("--daemon-base-url") ?? JSON.parse(readFileSync(valueOf("--config"), "utf8")).daemonBaseUrl); return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.port ? url : undefined; } catch { return undefined; } }; const local = informational || refused.has(name) ? undefined : endpoint(); if (local) { const failure = await new Promise(resolve => { diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 54a6185814038..c1c744ae1d50e 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -313,6 +313,23 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" stderr: `T3 Code's device connection at 127.0.0.1:${port} is not reachable from this shell (connect ECONNREFUSED). Call device_open again to reconnect; if that succeeds and this repeats, this shell runs in a network sandbox, so run the command with network access, outside the sandbox.\n`, }), ); + // An explicit --daemon-base-url wins over the config, as it does in agent-device. + const override = `http://127.0.0.1:${yield* listeningPort}`; + for (const flag of [["--daemon-base-url", override], [`--daemon-base-url=${override}`]]) { + expect( + JSON.parse( + (yield* Effect.promise(() => launch(["screenshot", "/tmp/shot.png", ...flag]))).stdout, + ), + ).toEqual([ + "screenshot", + "/tmp/shot.png", + ...flag, + "--config", + configPath, + "--session", + "t3-a", + ]); + } // cdp talks to Metro, not to T3's connection, so it still runs. expect( JSON.parse((yield* Effect.promise(() => launch(["cdp", "target", "list"]))).stdout), From 3b7a32ead7bf60216b8352aa93b1d56e789425f5 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Wed, 28 Oct 2026 13:18:32 -0400 Subject: [PATCH 30/30] test(server): raise the agent context budget for device guidance Codex's additional context with device access gains the device section (+1,178 bytes), and the device_open/device_close descriptions grow the MCP tool list by 65 bytes. Claude's system append is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/agentContextBudget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e67da4d775a89..d5496ea8cfc57 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, - "codexAdditionalContextBytes": 5687, - "mcpToolListJsonBytes": 45842 + "codexAdditionalContextBytes": 6865, + "mcpToolListJsonBytes": 45907 }