diff --git a/pins.json b/pins.json index 34ef55c..694cc39 100644 --- a/pins.json +++ b/pins.json @@ -23,7 +23,7 @@ }, "t3": { "repo": "pingdotgg/t3code", - "commit": "9bc4138923481dcada7f90d97ecaf0c42184bd4f", + "commit": "f855f016b3fe103f0b6cb05f26de08cb735b1f7b", "paths": [ "apps/server/src/mcp/toolkits/attachment/tools.ts", "apps/server/src/mcp/toolkits/device/tools.ts", diff --git a/t3/contract/apps/server/src/mcp/toolkits/device/tools.ts b/t3/contract/apps/server/src/mcp/toolkits/device/tools.ts index f02f39b..0a79af2 100644 --- a/t3/contract/apps/server/src/mcp/toolkits/device/tools.ts +++ b/t3/contract/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({ @@ -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 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, diff --git a/t3/contract/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/t3/contract/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 10cf6e2..413ab98 100644 --- a/t3/contract/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/t3/contract/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -222,7 +222,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List threads in a project, newest first. Omit `projectId` for yours. Filter by status, title, settled, or snoozed. Page with the cursor.", + "List threads newest first, in one project or with `allProjects`. Page with `cursor`.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, diff --git a/t3/contract/apps/server/src/mcp/toolkits/project/tools.ts b/t3/contract/apps/server/src/mcp/toolkits/project/tools.ts index 3893159..3ea5a8e 100644 --- a/t3/contract/apps/server/src/mcp/toolkits/project/tools.ts +++ b/t3/contract/apps/server/src/mcp/toolkits/project/tools.ts @@ -23,6 +23,7 @@ import { import * as FileSystem from "effect/FileSystem"; import * as DocumentText from "../../../provider/DocumentText.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as Crypto from "effect/Crypto"; @@ -166,6 +167,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, ], }) diff --git a/t3/contract/packages/contracts/src/device.ts b/t3/contract/packages/contracts/src/device.ts index 0fc7fde..24b72db 100644 --- a/t3/contract/packages/contracts/src/device.ts +++ b/t3/contract/packages/contracts/src/device.ts @@ -68,7 +68,32 @@ export const EasDeviceHostConfig = Schema.Struct({ ); export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type; -export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig; +/** + * Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock). + * T3 never installs Simlock; the gateway and its workers are the user's own. + */ +export const SimlockDeviceHostConfig = Schema.Struct({ + kind: Schema.Literal("simlock"), + id: ConfiguredDeviceHostId, + label: TrimmedNonEmptyString, + /** The gateway's origin, such as https://gateway.example.ts.net:4700. */ + gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)), + /** Leases, drives, watches, and takes input on its own leases. Kept in the secret store. */ + agentToken: TrimmedString.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("")), + Schema.annotateKey({ settingsSecret: true }), + ), + maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(2)), + ), + /** A lease the Device panel keeps alive ends this long after it started. */ + maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(60)), + ), +}); +export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type; + +export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig; const uniqueHostIds = Schema.makeFilter( (hosts: ReadonlyArray<{ readonly id: string }>) => @@ -85,6 +110,9 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniq */ export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds); +/** Kept apart from the other host lists for the same reason as EAS hosts. */ +export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds); + export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), @@ -99,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({ }); export type DeviceEasAccount = typeof DeviceEasAccount.Type; +export const DeviceSimlockIdentityInput = Schema.Struct({ + /** Without one, the gateway this machine's agent-device points at. */ + gatewayUrl: Schema.optional(TrimmedString), + /** A saved Simlock host whose stored token is checked when no new token is given. */ + hostId: Schema.optional(DeviceHostId), + agentToken: Schema.optional(TrimmedString), +}); +export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type; + +export const DeviceSimlockIdentity = Schema.Struct({ + gatewayUrl: Schema.String, + /** The label the gateway gave the token. */ + signedInAs: Schema.String, + /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */ + tokenSource: Schema.Literals(["entered", "settings", "env"]), +}); +export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type; + +export class DeviceSimlockIdentityError extends Schema.TaggedError()( + "DeviceSimlockIdentityError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + /** Checking an Expo credential failed; no device host is involved yet. */ export class DeviceEasAccountError extends Schema.TaggedError()( "DeviceEasAccountError", @@ -161,7 +216,15 @@ const EasDeviceHostSummary = Schema.Struct({ ...deviceHostSummaryFields, kind: Schema.Literal("eas"), }); -export const DeviceHostSummary = Schema.Union([MachineDeviceHostSummary, EasDeviceHostSummary]); +const SimlockDeviceHostSummary = Schema.Struct({ + ...deviceHostSummaryFields, + kind: Schema.Literal("simlock"), +}); +export const DeviceHostSummary = Schema.Union([ + MachineDeviceHostSummary, + EasDeviceHostSummary, + SimlockDeviceHostSummary, +]); export type DeviceHostSummary = typeof DeviceHostSummary.Type; export const DeviceHostStatus = Schema.Literals([ @@ -198,6 +261,8 @@ const deviceServiceStateFields = { supportsToolInspection: Schema.optional(Schema.Boolean), /** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */ supportsEasHosts: Schema.optional(Schema.Boolean), + /** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */ + supportsSimlockHosts: Schema.optional(Schema.Boolean), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), hostStatuses: DeviceHostStatuses, @@ -221,6 +286,7 @@ const DeviceServiceStateWire = Schema.Struct({ ...deviceServiceStateFields, hosts: Schema.Array(MachineDeviceHostSummary), easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)), + simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)), }); const DeviceServiceStateDecoded = Schema.Struct({ ...deviceServiceStateFields, @@ -234,18 +300,22 @@ export const DeviceServiceState = DeviceServiceStateWire.pipe( typeof DeviceServiceStateDecoded.Type, typeof DeviceServiceStateWire.Type >({ - decode: ({ easHosts, ...state }) => ({ + decode: ({ easHosts, simlockHosts, ...state }) => ({ ...state, - hosts: [...state.hosts, ...(easHosts ?? [])], + hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])], }), encode: (state) => ({ ...state, hosts: state.hosts.filter( - (host): host is typeof MachineDeviceHostSummary.Type => host.kind !== "eas", + (host): host is typeof MachineDeviceHostSummary.Type => + host.kind === "local" || host.kind === "ssh", ), easHosts: state.hosts.filter( (host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas", ), + simlockHosts: state.hosts.filter( + (host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock", + ), }), }), ), @@ -492,13 +562,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.`; } } @@ -584,6 +655,7 @@ export const DeviceToolOpenResult = Schema.Struct({ agentDevice: Schema.Struct({ command: Schema.String, targetArgs: Schema.Array(Schema.String), + session: Schema.String, }), quickStart: Schema.String, }); diff --git a/t3/contract/packages/contracts/src/orchestratorMcp.ts b/t3/contract/packages/contracts/src/orchestratorMcp.ts index 9b91002..1474a5d 100644 --- a/t3/contract/packages/contracts/src/orchestratorMcp.ts +++ b/t3/contract/packages/contracts/src/orchestratorMcp.ts @@ -330,6 +330,7 @@ export const OrchestratorMcpEnvironmentTarget = Schema.optional( export const OrchestratorMcpThreadListInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, + allProjects: Schema.optional(Schema.Boolean), statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), ), @@ -353,6 +354,7 @@ export type OrchestratorMcpThreadPullRequest = typeof OrchestratorMcpThreadPullR export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, + projectId: ProjectId, title: Schema.String, link: Schema.optional(Schema.String), createdBy: OrchestrationV2Actor, @@ -378,7 +380,8 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem.Type; export const OrchestratorMcpThreadListResult = Schema.Struct({ - projectId: ProjectId, + /** null with `allProjects`. */ + projectId: Schema.NullOr(ProjectId), currentThreadId: Schema.NullOr(ThreadId), threads: Schema.Array(OrchestratorMcpThreadListItem), nextCursor: Schema.NullOr(NonNegativeInt), diff --git a/t3/contract/packages/provider-core/src/server/orchestrationInstructions.ts b/t3/contract/packages/provider-core/src/server/orchestrationInstructions.ts index 6737fde..428aec2 100644 --- a/t3/contract/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/t3/contract/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\` 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 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, 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(""); + +/** 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,38 @@ 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, 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) + 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; } -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; }