authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 11:59:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 11:59:33-07:00
log7734c6d71b6dd5df9c9b4856cff8e419b8837fe8
tree505220ed3094e21eb14a87e2a68f21a9b70f6466
parente022fa430edbf8d448efe0f34303ff290ebb3dc0
signature Signed by PGP key B5690EEEBB952194

feat(devices): target concurrent agent sessions across hosts (#10855)


11 files changed, 376 insertions(+), 48 deletions(-)

apps/server/src/device/AgentDeviceShim.ts+26-2
......@@ -1,3 +1,4 @@
1// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.
12/**
23 * A directory holding an `agent-device` launcher that runs the pinned install
34 * with the server's Node. Prepended to provider subprocess PATHs so the agent
......@@ -22,11 +23,34 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio
2223 const shimDir = path.join(input.stateDir, SHIM_DIR);
2324 yield* fs.makeDirectory(shimDir, { recursive: true });
2425 const node = process.execPath;
26 const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");
27 yield* fs.writeFileString(
28 launcherPath,
29 `import { spawn } from "node:child_process";
30const args = process.argv.slice(2);
31const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]);
32const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); };
33if (!informational && !(hasValue("--config") && hasValue("--session"))) {
34 console.error("Call device_open first and include its --config and --session flags.");
35 process.exit(1);
36}
37const env = { ...process.env };
38delete env.AGENT_DEVICE_DAEMON_BASE_URL;
39delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
40delete env.AGENT_DEVICE_CONFIG;
41const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env });
42child.on("error", error => { console.error(error.message); process.exitCode = 1; });
43child.on("exit", code => { process.exitCode = code ?? 1; });
44`,
45 );
2546 if (platform === "win32") {
26 const script = `@echo off\r\n"${node}" "${entryPath}" %*\r\n`;
47 const script = `@echo off\r\n"${node}" "${launcherPath}" %*\r\n`;
2748 yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script);
2849 } else {
29 const script = `#!/bin/sh\nexec "${node}" "${entryPath}" "$@"\n`;
50 const command = [node, launcherPath]
51 .map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'")
52 .join(" ");
53 const script = `#!/bin/sh\nexec ${command} "$@"\n`;
3054 const shimPath = path.join(shimDir, "agent-device");
3155 yield* fs.writeFileString(shimPath, script);
3256 yield* fs.chmod(shimPath, 0o755);
apps/server/src/device/AgentDeviceTarget.test.ts created+89
......@@ -0,0 +1,89 @@
1// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import { describe, expect, it } from "@effect/vitest";
4import * as NodeChildProcess from "node:child_process";
5import * as NodeUtil from "node:util";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts";
11import {
12 agentDeviceConfigPath,
13 agentDeviceSession,
14 writeAgentDeviceConfig,
15} from "./AgentDeviceTarget.ts";
16
17const exec = NodeUtil.promisify(NodeChildProcess.execFile);
18
19describe("host-bound agent commands", () => {
20 it.effect("runs two hosts concurrently and only updates the reconnected host", () =>
21 Effect.gen(function* () {
22 const fs = yield* FileSystem.FileSystem;
23 const path = yield* Path.Path;
24 const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" });
25 const platform = yield* HostProcessPlatform;
26 const dir = path.join(
27 temp,
28 platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`",
29 );
30 yield* fs.makeDirectory(dir);
31 const entryPath = path.join(dir, "cli.mjs");
32 yield* fs.writeFileString(
33 entryPath,
34 `import { readFileSync } from 'node:fs';
35const args = process.argv.slice(2);
36console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));
37if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
38 );
39 const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
40 const files = ["mini", "android"].map((host) => agentDeviceConfigPath(dir, host, path));
41 for (const [index, file] of files.entries())
42 yield* writeAgentDeviceConfig(file, {
43 baseUrl: `http://127.0.0.1:${1000 + index}`,
44 token: `token-${index}`,
45 entryPath,
46 });
47 const invoke = (file: string) =>
48 exec(
49 platform === "win32" ? process.execPath : path.join(shim, "agent-device"),
50 [
51 ...(platform === "win32" ? [path.join(shim, "agent-device-launcher.mjs")] : []),
52 "snapshot",
53 "--config",
54 file,
55 "--session",
56 "test-session",
57 ],
58 { env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } },
59 ).then((result) => JSON.parse(result.stdout));
60 expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([
61 { daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" },
62 { daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" },
63 ]);
64 const second = yield* fs.readFileString(files[1]!);
65 yield* writeAgentDeviceConfig(files[0]!, {
66 baseUrl: "http://127.0.0.1:2000",
67 token: "new",
68 entryPath,
69 });
70 expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
71 expect(yield* fs.readFileString(files[1]!)).toBe(second);
72 expect(agentDeviceSession("thread", "mini", "same-id")).not.toBe(
73 agentDeviceSession("thread", "android", "same-id"),
74 );
75 for (const args of [
76 ["snapshot"],
77 ["snapshot", "--config", files[0]!],
78 ["snapshot", "--config", "help"],
79 ["snapshot", "--config", files[0]!, "--session"],
80 ]) {
81 yield* Effect.promise(() =>
82 expect(
83 exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]),
84 ).rejects.toThrow("Call device_open first"),
85 );
86 }
87 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
88 );
89});
apps/server/src/device/AgentDeviceTarget.ts created+43
......@@ -0,0 +1,43 @@
1import * as NodeCrypto from "node:crypto";
2import * as Schema from "effect/Schema";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6
7import type { AgentDeviceEndpoint } from "./DeviceHost.ts";
8
9const encodeEndpoint = Schema.encodeEffect(
10 Schema.fromJsonString(
11 Schema.Struct({ daemonBaseUrl: Schema.String, daemonAuthToken: Schema.String }),
12 ),
13);
14
15const key = (value: string) =>
16 NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24);
17
18/** A stable file per host lets forwarded endpoints change without retargeting other commands. */
19export const agentDeviceConfigPath = (stateDir: string, hostId: string, path: Path.Path) =>
20 path.join(stateDir, "device", "hosts", `${key(hostId)}.json`);
21
22export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) =>
23 `t3-${key(JSON.stringify([threadId, hostId, deviceId]))}`;
24
25export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (
26 file: string,
27 endpoint: AgentDeviceEndpoint,
28) {
29 const fs = yield* FileSystem.FileSystem;
30 const path = yield* Path.Path;
31 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
32 const content = yield* encodeEndpoint({
33 daemonBaseUrl: endpoint.baseUrl,
34 daemonAuthToken: endpoint.token,
35 });
36 if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return;
37 const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" });
38 yield* Effect.gen(function* () {
39 yield* fs.chmod(temporary, 0o600);
40 yield* fs.writeFileString(temporary, content);
41 yield* fs.rename(temporary, file);
42 }).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore)));
43});
apps/server/src/device/DeviceService.test.ts+2-2
......@@ -16,7 +16,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1616import { ServerSettingsService } from "../serverSettings.ts";
1717import * as DeviceHost from "./DeviceHost.ts";
1818
19import { type DeviceService, make, stateStream } from "./DeviceService.ts";
19import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts";
2020
2121const baseState: DeviceServiceState = {
2222 hosts: [],
......@@ -108,7 +108,7 @@ const fixture = Effect.fn("fixture")(function* (
108108 starts.push("stop");
109109 }),
110110 };
111 const service = yield* make.pipe(
111 const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe(
112112 Effect.provideService(DeviceHost.DeviceHost, host),
113113 Effect.provideService(
114114 ServerSettingsService,
apps/server/src/device/DeviceService.ts+88-2
......@@ -33,6 +33,15 @@ import {
3333 LOCAL_DEVICE_HOST_ID,
3434 type ThreadId,
3535} from "@t3tools/contracts";
36import * as FileSystem from "effect/FileSystem";
37import * as Path from "effect/Path";
38import { ensureAgentDevice } from "./DeviceToolchain.ts";
39import * as ServerConfig from "../config.ts";
40import {
41 agentDeviceConfigPath,
42 agentDeviceSession,
43 writeAgentDeviceConfig,
44} from "./AgentDeviceTarget.ts";
3645import * as Context from "effect/Context";
3746import * as DateTime from "effect/DateTime";
3847import * as Duration from "effect/Duration";
......@@ -49,6 +58,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
4958import * as ServerSettings from "../serverSettings.ts";
5059
5160import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
61import * as ProcessRunner from "../processRunner.ts";
5262import * as DeviceHost from "./DeviceHost.ts";
5363import * as LocalDeviceHost from "./LocalDeviceHost.ts";
5464
......@@ -94,6 +104,12 @@ export interface DeviceAgentReadiness extends DeviceReadiness {
94104export class DeviceService extends Context.Service<
95105 DeviceService,
96106 {
107 readonly agentCli: Effect.Effect<string, DeviceError>;
108 readonly agentTarget: (input: {
109 threadId: ThreadId;
110 hostId: DeviceHostId;
111 deviceId: DeviceId;
112 }) => Effect.Effect<ReadonlyArray<string>, DeviceError>;
97113 readonly state: Effect.Effect<DeviceServiceState>;
98114 readonly subscribe: Effect.Effect<PubSub.Subscription<DeviceServiceState>, never, Scope.Scope>;
99115 readonly configure: (
......@@ -138,6 +154,16 @@ const vendorPrefix = (platform: DevicePlatform) =>
138154
139155export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
140156 hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,
157 configureAgent: (
158 hostId: DeviceHostId,
159 ready: DeviceHost.DeviceHostAgentReady,
160 ) => Effect.Effect<string, DeviceError> = (hostId) =>
161 Effect.fail(
162 new DeviceHostUnavailableError({
163 hostId,
164 reason: "Agent configuration is unavailable in this device service.",
165 }),
166 ),
141167) {
142168 const settings = yield* ServerSettings.ServerSettingsService;
143169 const lifecycleLock = yield* Semaphore.make(1);
......@@ -733,6 +759,29 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
733759 );
734760
735761 return DeviceService.of({
762 agentCli: Effect.fail(
763 new DeviceHostUnavailableError({
764 hostId: LOCAL_DEVICE_HOST_ID,
765 reason: "Agent CLI installation is unavailable in this device service.",
766 }),
767 ),
768 agentTarget: (input) =>
769 Effect.gen(function* () {
770 const ready = yield* agentReadinessIfSupported(input.hostId);
771 if (!ready)
772 return yield* new DeviceHostUnavailableError({
773 hostId: input.hostId,
774 reason:
775 "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
776 });
777 const configPath = yield* configureAgent(input.hostId, ready);
778 return [
779 "--config",
780 configPath,
781 "--session",
782 agentDeviceSession(input.threadId, input.hostId, input.deviceId),
783 ];
784 }),
736785 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
737786 subscribe: PubSub.subscribe(statePubSub),
738787 configure,
......@@ -751,9 +800,46 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
751800 });
752801});
753802
803/** @public Service construction is part of the canonical Effect module API. */
754804export const make = Effect.gen(function* () {
755 const host = yield* DeviceHost.DeviceHost;
756 return yield* makeWithHosts(new Map([[host.id, host]]));
805 const localHost = yield* DeviceHost.DeviceHost;
806 const config = yield* ServerConfig.ServerConfig;
807 const fs = yield* FileSystem.FileSystem;
808 const path = yield* Path.Path;
809 const runner = yield* ProcessRunner.ProcessRunner;
810 const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => {
811 const file = agentDeviceConfigPath(config.stateDir, hostId, path);
812 return writeAgentDeviceConfig(file, ready.agentDevice).pipe(
813 Effect.provideService(FileSystem.FileSystem, fs),
814 Effect.provideService(Path.Path, path),
815 Effect.mapError(
816 (cause) =>
817 new DeviceOperationError({
818 operation: "configure agent",
819 reason: "settings_failed",
820 cause,
821 }),
822 ),
823 Effect.as(file),
824 );
825 });
826 return {
827 ...service,
828 agentCli: ensureAgentDevice(config.baseDir).pipe(
829 Effect.provideService(FileSystem.FileSystem, fs),
830 Effect.provideService(Path.Path, path),
831 Effect.provideService(ProcessRunner.ProcessRunner, runner),
832 Effect.map((tool) => tool.entryPath),
833 Effect.mapError(
834 (error) =>
835 new DeviceOperationError({
836 operation: "install agent CLI",
837 reason: "command_failed",
838 cause: error,
839 }),
840 ),
841 ),
842 };
757843});
758844
759845export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
apps/server/src/mcp/McpDeviceToolkit.test.ts+48-1
......@@ -1,10 +1,16 @@
11import { expect, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
3import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import {
4 DeviceHostUnavailableError,
5 EnvironmentId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
49import * as Effect from "effect/Effect";
510import * as Layer from "effect/Layer";
611import { McpSchema, McpServer } from "effect/unstable/ai";
712
13import * as ServerConfig from "../config.ts";
814import * as DeviceService from "../device/DeviceService.ts";
915import * as McpHttpServer from "./McpHttpServer.ts";
1016import * as McpInvocationContext from "./McpInvocationContext.ts";
......@@ -85,11 +91,14 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
8591 sessionsForThread: () => Effect.succeed([]),
8692 screenshot: () => Effect.succeed({ device, png }),
8793 close: () => Effect.void,
94 agentCli: Effect.succeed("/cli"),
95 agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),
8896});
8997
9098const TestLayer = McpHttpServer.DeviceToolkitRegistrationLive.pipe(
9199 Layer.provideMerge(McpServer.McpServer.layer),
92100 Layer.provideMerge(DeviceServiceMock),
101 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })),
93102 Layer.provide(NodeServices.layer),
94103);
95104
......@@ -126,3 +135,41 @@ it.effect("registers the device tools and returns the screenshot as image conten
126135 }),
127136 ).pipe(Effect.provide(TestLayer)),
128137);
138
139it.effect("rejects unavailable agent access before booting or opening a device", () => {
140 const unavailable = Layer.mock(DeviceService.DeviceService)({
141 list: Effect.succeed(state),
142 agentTarget: () =>
143 Effect.fail(
144 new DeviceHostUnavailableError({ hostId: "local", reason: "Agent access is disabled." }),
145 ),
146 open: () => Effect.die("Must not boot or register a device when agent access fails"),
147 });
148 return Effect.gen(function* () {
149 const server = yield* McpServer.McpServer;
150 const result = yield* server
151 .callTool({ name: "device_open", arguments: { platform: "ios" } })
152 .pipe(
153 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])),
154 Effect.provideService(McpSchema.McpServerClient, client),
155 );
156 expect(result.isError).toBe(true);
157 expect(result.content).toEqual(
158 expect.arrayContaining([
159 expect.objectContaining({
160 type: "text",
161 text: expect.stringContaining("Agent access is disabled."),
162 }),
163 ]),
164 );
165 }).pipe(
166 Effect.scoped,
167 Effect.provide(
168 McpHttpServer.DeviceToolkitRegistrationLive.pipe(
169 Layer.provideMerge(McpServer.McpServer.layer),
170 Layer.provide(unavailable),
171 Layer.provide(NodeServices.layer),
172 ),
173 ),
174 );
175});
apps/server/src/mcp/toolkits/device/handlers.test.ts+13
......@@ -30,6 +30,19 @@ describe("device tool helpers", () => {
3030 expect(text).toContain("XCTest runner");
3131 });
3232
33 it("uses the absolute launcher in every quick-start command", () => {
34 const text = agentDeviceQuickStart(
35 device,
36 ["--session", "thread-1", "--config", "/tmp/host.json"],
37 "/tmp/t3 tools/agent-device",
38 );
39 expect(text).toContain(
40 "'/tmp/t3 tools/agent-device' snapshot -i --session thread-1 --config /tmp/host.json",
41 );
42 expect(text).not.toContain(" agent-device ");
43 expect(text).not.toContain("is on PATH");
44 });
45
3346 it("reads PNG dimensions from the IHDR chunk", () => {
3447 const png = new Uint8Array(24);
3548 new DataView(png.buffer).setUint32(0, 0x89504e47);
apps/server/src/mcp/toolkits/device/handlers.ts+54-12
......@@ -8,6 +8,10 @@ import {
88 LOCAL_DEVICE_HOST_ID,
99} from "@t3tools/contracts";
1010import * as Effect from "effect/Effect";
11import * as Path from "effect/Path";
12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
13import { ServerConfig } from "../../../config.ts";
14import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts";
1115
1216import * as DeviceService from "../../../device/DeviceService.ts";
1317import * as McpInvocationContext from "../../McpInvocationContext.ts";
......@@ -26,24 +30,37 @@ export function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray<stri
2630 * rather than in the always-on prompt block; threads that never open a device
2731 * never pay for it.
2832 */
29export function agentDeviceQuickStart(device: DeviceSummary): string {
30 const target = agentDeviceTargetArgs(device).join(" ");
33export function agentDeviceQuickStart(
34 device: DeviceSummary,
35 targetArgs = agentDeviceTargetArgs(device),
36 command = "agent-device",
37): string {
38 const executable = /^[a-zA-Z0-9_./:-]+$/.test(command)
39 ? command
40 : "'" + command.replaceAll("'", "'\"'\"'") + "'";
41 const target = targetArgs
42 .map((arg) =>
43 /^[a-zA-Z0-9_./:-]+$/.test(arg) ? arg : "'" + arg.replaceAll("'", "'\"'\"'") + "'",
44 )
45 .join(" ");
3146 const platformNotes =
3247 device.platform === "ios"
3348 ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast."
3449 : "The Android snapshot helper installs itself on first use.";
3550 return [
3651 `The user is watching ${device.name} (${device.version}) in the Device panel.`,
37 `Drive it with the agent-device CLI, which is on PATH and already connected to this environment. Always pass ${target}.`,
52 `Drive it with ${executable}. Use this exact executable path; login shells may reset PATH. Always pass ${target}.`,
3853 "Typical loop:",
39 ` agent-device open <bundle-or-package-id> ${target} # or: open <app> <deep-link-url>`,
40 ` agent-device snapshot -i ${target} # accessibility tree with @eN refs`,
41 ` agent-device click @e3 ${target}`,
42 ` agent-device fill @e5 "text" ${target}`,
43 ` agent-device screenshot /tmp/shot.png ${target} # or call device_screenshot`,
44 ` agent-device install <app> <path-to-.app-or-.apk> ${target}`,
45 "Prefer snapshot refs over coordinates. Run `agent-device help` for workflow guides and `agent-device <command> --help` for flags.",
54 ` ${executable} open <bundle-or-package-id> ${target} # or: open <app> <deep-link-url>`,
55 ` ${executable} snapshot -i ${target} # accessibility tree with @eN refs`,
56 ` ${executable} click @e3 ${target}`,
57 ` ${executable} fill @e5 "text" ${target}`,
58 ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`,
59 ` ${executable} install <app> <path-to-.app-or-.apk> ${target}`,
60 `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} <command> --help for flags.`,
4661 "Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.",
62 "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.",
63 "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.",
4764 platformNotes,
4865 ].join("\n");
4966}
......@@ -136,6 +153,12 @@ const handlers = {
136153 });
137154 }
138155 const target = yield* pickDevice(state.devices, input);
156 // Resolve consent and agent connectivity before booting or registering a session.
157 const agentArgs = yield* devices.agentTarget({
158 threadId: scope.threadId,
159 hostId: target.hostId,
160 deviceId: target.id,
161 });
139162 const session = yield* devices.open({
140163 threadId: scope.threadId,
141164 hostId: target.hostId,
......@@ -147,10 +170,29 @@ const handlers = {
147170 after.devices.find(
148171 (candidate) => candidate.hostId === session.hostId && candidate.id === session.deviceId,
149172 ) ?? target;
173 const targetArgs = [...agentDeviceTargetArgs(device), ...agentArgs];
174 const config = yield* ServerConfig;
175 const path = yield* Path.Path;
176 const platform = yield* HostProcessPlatform;
177 const shimDir = yield* ensureAgentDeviceShim({
178 entryPath: yield* devices.agentCli,
179 stateDir: config.stateDir,
180 }).pipe(
181 Effect.mapError(
182 () =>
183 new DeviceToolUnavailableError({
184 reason: "Could not prepare the agent-device launcher.",
185 }),
186 ),
187 );
188 const command = path.join(
189 shimDir,
190 platform === "win32" ? "agent-device.cmd" : "agent-device",
191 );
150192 return {
151193 device,
152 agentDevice: { command: "agent-device", targetArgs: agentDeviceTargetArgs(device) },
153 quickStart: agentDeviceQuickStart(device),
194 agentDevice: { command, targetArgs },
195 quickStart: agentDeviceQuickStart(device, targetArgs, command),
154196 };
155197 }).pipe(Effect.mapError(toolError)),
156198 device_screenshot: (input) =>
apps/server/src/mcp/toolkits/device/tools.ts+4-1
......@@ -8,6 +8,9 @@ import {
88 DeviceToolTargetInput,
99} from "@t3tools/contracts";
1010import * as Schema from "effect/Schema";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import { ServerConfig } from "../../../config.ts";
1114import { Tool, Toolkit } from "effect/unstable/ai";
1215
1316import * as McpInvocationContext from "../../McpInvocationContext.ts";
......@@ -48,7 +51,7 @@ const DeviceOpenTool = Tool.make("device_open", {
4851 parameters: DeviceToolOpenInput,
4952 success: DeviceToolOpenResult,
5053 failure: DeviceToolError,
51 dependencies,
54 dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig],
5255})
5356 .annotate(Tool.Title, "Open device")
5457 .annotate(Tool.Readonly, false)
apps/server/src/provider/CodexDeveloperInstructions.ts+1-1
......@@ -16,7 +16,7 @@ const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `
1616
1717## T3 Code devices
1818
19The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH and already connected: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.
19The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.
2020`;
2121
2222export interface T3CodeToolAvailability {
apps/server/src/provider/Layers/ProviderService.ts+8-27
......@@ -54,8 +54,8 @@ import * as Stream from "effect/Stream";
5454import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts";
5555import { resolveAttachmentPath } from "../../attachmentStore.ts";
5656import * as ServerConfig from "../../config.ts";
57import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts";
5857import * as DeviceService from "../../device/DeviceService.ts";
58import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts";
5959import type * as McpInvocationContext from "../../mcp/McpInvocationContext.ts";
6060import {
6161 increment,
......@@ -254,8 +254,6 @@ export interface ProviderServiceLiveOptions {
254254 * test see whether a credential was requested at all.
255255 */
256256 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
257 /** Overrides the device host lookup used to build the agent-device environment. */
258 readonly deviceReadiness?: DeviceService.DeviceService["Service"]["agentReadinessIfSupported"];
259257}
260258
261259interface TurnAnalyticsMetadata {
......@@ -484,14 +482,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
484482 );
485483 const issueMcpCredential =
486484 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
487 const deviceReadiness =
488 options?.deviceReadiness ??
489 (() =>
490 Effect.serviceOption(DeviceService.DeviceService).pipe(
491 Effect.flatMap((service) =>
492 Option.isSome(service) ? service.value.agentReadinessIfSupported() : Effect.succeed(null),
493 ),
494 ));
495485 const fileSystem = yield* FileSystem.FileSystem;
496486 const pathService = yield* Path.Path;
497487 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
......@@ -914,26 +904,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
914904 return capabilities;
915905 });
916906
917 /**
918 * Starting a session with device access also brings the device host up, so
919 * the `agent-device` CLI is on the provider's PATH from its first turn. The
920 * environment is fixed at spawn time, so a host started later by
921 * `device_open` could not reach an already-running agent. Tools install once
922 * and the host is idempotent, so this is cheap after the first session;
923 * a host that fails to start withholds only the CLI, not the MCP tools.
924 */
907 /** Install only the local CLI here. device_open supplies a separate config for each host. */
925908 const hostPlatform = yield* HostProcessPlatform;
926909 const agentDeviceEnvironment = Effect.gen(function* () {
927 const readiness = yield* deviceReadiness().pipe(
910 const devices = yield* Effect.serviceOption(DeviceService.DeviceService);
911 if (Option.isNone(devices)) return undefined;
912 const entryPath = yield* devices.value.agentCli.pipe(
928913 Effect.catch((cause) =>
929 Effect.logWarning("Device host unavailable; starting session without agent-device", {
930 cause,
931 }).pipe(Effect.as(null)),
914 Effect.logWarning("Agent device CLI unavailable", { cause }).pipe(Effect.as(null)),
932915 ),
933916 );
934 if (!readiness) return undefined;
917 if (!entryPath) return undefined;
935918 const shimDir = yield* ensureAgentDeviceShim({
936 entryPath: readiness.agentDevice.entryPath,
919 entryPath,
937920 stateDir: serverConfig.stateDir,
938921 }).pipe(
939922 Effect.provideService(FileSystem.FileSystem, fileSystem),
......@@ -944,8 +927,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
944927 return {
945928 PATH: shimDir,
946929 PATH_SEPARATOR: hostPlatform === "win32" ? ";" : ":",
947 AGENT_DEVICE_DAEMON_BASE_URL: readiness.agentDevice.baseUrl,
948 AGENT_DEVICE_DAEMON_AUTH_TOKEN: readiness.agentDevice.token,
949930 AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
950931 } satisfies Record<string, string>;
951932 });