diff --git a/apps/mobile/assets/context-chip-icons/server.png b/apps/mobile/assets/context-chip-icons/server.png new file mode 100644 index 0000000000000..92bc2ce457f16 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/server.png differ diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs index aac4983d75e2c..d99d4d3c98478 100644 --- a/apps/mobile/scripts/sync-context-chip-icons.mjs +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -43,6 +43,7 @@ const lucideIcons = [ "message-circle", "messages-square", "mouse-pointer-click", + "server", "terminal", ]; diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d6..8121bfdece763 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -324,6 +324,9 @@ export function ComposerContextSheet(props: { ) : null} ) : null} + {record.kind === "environment" ? ( + + ) : null} {record.kind === "skill" ? ( diff --git a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx index 66dd7dfd8f485..9153c9fa6a1c8 100644 --- a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx +++ b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx @@ -1,3 +1,4 @@ +import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request"; import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; import { useFocusEffect } from "@react-navigation/native"; import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts"; @@ -18,7 +19,7 @@ export function LinkedEnvironmentsSection(props: { }) { const config = useEnvironmentServerConfig(props.environmentId); const canRead = useEnvironmentScope(props.environmentId, AuthAccessReadScope); - const supported = canRead && config?.environment.capabilities.peerLinks === true; + const supported = canRead && offersLinkedEnvironments(config); const links = useEnvironmentQuery( supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null, ); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 179eceba6c6fd..ef62306accbdc 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -57,6 +57,7 @@ vi.mock("../../state/session", () => ({ ), })); vi.mock("../../state/server", () => ({ serverEnvironment: { updateSettings: {} } })); +vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [] }) })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.updateSettings, })); diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx index f982820a71610..5149ffa54c6fc 100644 --- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx +++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx @@ -11,7 +11,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/ui/AppText"; +import { useEnvironments } from "../../state/environments"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; @@ -31,6 +34,7 @@ export function SettingsLabsRouteScreen() { contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + @@ -214,6 +218,47 @@ export function NewThreadSettingsSection() { ); } +/** Per machine, since each machine's own setting decides whether it links and answers links. */ +function BetaSettingsSection() { + const { environments } = useEnvironments(); + const supported = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.peerLinks === true, + ); + const writable = useEnvironmentsWithScope(supported, AuthSettingsWriteScope); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "linked environments beta update", + reportFailure: true, + }); + if (supported.length === 0) return null; + return ( + + + {supported.map((environment) => ( + + void updateSettings({ + environmentId: environment.environmentId, + input: { patch: { linkedEnvironmentsEnabled: value } }, + }) + } + /> + ))} + + + Let agents on a machine work on your other machines, and theirs on it. Turn it on for each + machine. While it is off, nothing new starts over a link; tasks already running finish. + + + ); +} + function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferences = useAtomValue(mobilePreferencesAtom); diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 84bc68ec712bc..c11a89a7c3a10 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -83,7 +83,7 @@ const SETTINGS_SEARCH_TERMS = { "submodules", "start from remote", ], - SettingsLabs: ["legacy", "plan mode", "beta"], + SettingsLabs: ["legacy", "plan mode", "beta", "linked environments", "link machine"], SettingsEnvironmentMaintenance: ["updates", "provider updates", "restart", "manage environments"], SettingsAbout: [ "version", diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cd924e4c61f03..cbe89f5fb8768 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -3,6 +3,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import type { + EnvironmentId, + EnvironmentMachineKind, PullRequestContextMetadata, ScopedThreadRef, ServerProviderSkill, @@ -14,6 +16,7 @@ import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-n import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; +import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachineSymbol"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; @@ -40,6 +43,14 @@ export type ComposerCommandItem = readonly label: string; readonly description: string; } + | { + readonly id: string; + readonly type: "environment"; + readonly environmentId: EnvironmentId; + readonly machine: EnvironmentMachineKind; + readonly label: string; + readonly description: string; + } | { readonly id: string; readonly type: "slash-command"; @@ -110,6 +121,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null { return null; case "thread": return "text.bubble"; + case "environment": + return ENVIRONMENT_MACHINE_SYMBOLS[item.machine]; } } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7ac1ad83aa8b5..1e55ebc3d5884 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -66,6 +66,7 @@ import { import { appAtomRegistry } from "../../state/atom-registry"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject, useThreadShells } from "../../state/entities"; +import { useMentionableEnvironments } from "../../state/environments"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { AppText as Text } from "../../components/ui/AppText"; @@ -99,6 +100,8 @@ import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor"; import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; +import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems"; +import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request"; import { ComposerDictationCancelAction, ComposerDictationDraftContent, @@ -117,6 +120,7 @@ import { export const COMPOSER_COLLAPSED_CHROME = 60; +const NO_MACHINES: ReadonlyArray = []; const COLLAPSED_EDITOR_DEFAULT_INSET = 6; // iOS puts line-height slack above glyphs. DM Sans's lowercase midline is // descender (0.31em) + half x-height (0.504em) above the line bottom. @@ -523,11 +527,15 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps return report !== null; }; + const mentionableEnvironments = useMentionableEnvironments(); const composerMenu = useComposerCommandMenu({ draftMessage: draftMessage, ownerKey: composerDraftKey, environmentId: props.environmentId, threadShells: useThreadShells(), + environments: offersLinkedEnvironments(props.serverConfig) + ? mentionableEnvironments + : NO_MACHINES, currentThreadId: props.selectedThread.id, projectCwd: props.projectCwd, pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index af4091a5f023d..89afc891015ac 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -7,13 +7,22 @@ import type { ThreadId, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; +import { + type ComposerEnvironmentCandidate, + matchComposerEnvironmentItems, + matchComposerThreadItems, +} from "@t3tools/client-runtime/composerThreadItems"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; const EMPTY_THREAD_SHELLS: ReadonlyArray = []; +const EMPTY_ENVIRONMENTS: ReadonlyArray = []; import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; +import { + environmentComposerContext, + pullRequestComposerContext, + threadComposerContext, +} from "../../lib/composerContext"; import { uuidv4 } from "../../lib/uuid"; import { getComposerDraftSnapshot, @@ -201,6 +210,7 @@ export function useComposerCommandMenu({ ownerKey, environmentId, threadShells = EMPTY_THREAD_SHELLS, + environments = EMPTY_ENVIRONMENTS, currentThreadId = null, projectCwd, pullRequestProjectId = null, @@ -219,6 +229,7 @@ export function useComposerCommandMenu({ readonly ownerKey: string | null; readonly environmentId: EnvironmentId | null; readonly threadShells?: ReadonlyArray; + readonly environments?: ReadonlyArray; readonly currentThreadId?: ThreadId | null; readonly projectCwd: string | null; readonly pullRequestProjectId?: ProjectId | null; @@ -452,8 +463,12 @@ export function useComposerCommandMenu({ now, }) : []; + const environmentItems = environmentId + ? matchComposerEnvironmentItems({ environments, environmentId, query: trigger.query }) + : []; return [ ...threadItems, + ...environmentItems, ...pathSearch.entries.map((entry) => { const parts = entry.path.split("/"); return { @@ -473,6 +488,32 @@ export function useComposerCommandMenu({ const onSelect = (item: ComposerCommandItem) => { if (!trigger) return; + if (item.type === "environment") { + if (!ownerKey || trigger.kind !== "path") return; + const record = environmentComposerContext(item.environmentId, item.label); + const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? []; + const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId); + if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) { + Dialog.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + const result = replaceTextRange( + draftMessage, + trigger.rangeStart, + trigger.rangeEnd, + `${formatComposerContextReference(record)} `, + ); + onChangeDraftMessage(result.text); + if (!alreadyAttached) { + const draft = getComposerDraftSnapshot(ownerKey); + setComposerDraftContext(ownerKey, { + version: 1, + records: [...(draft.context?.records ?? []), record], + }); + } + setSelection({ start: result.cursor, end: result.cursor }); + return; + } if (item.type === "thread") { if (!ownerKey || trigger.kind !== "path") return; const shell = threadShells.find( diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts index fa578904dcb54..21474f6de7e96 100644 --- a/apps/mobile/src/lib/composerChipPresentation.test.ts +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -22,6 +22,7 @@ const CHIP_ACCENTS = [ "pull-request", "skill", "thread", + "environment", ].map((kind) => contextChipPresentation(kind)), ...[ { state: "open", isDraft: false }, @@ -107,3 +108,9 @@ describe("contextChipAccent", () => { expect(contextChipAccent("#009f6e", theme, theme["--color-md-body"])).toBe("#009f6e"); }); }); + +describe("contextChipPresentation", () => { + it("draws a mentioned machine as a machine, not the generic file chip", () => { + expect(contextChipPresentation("environment")).toEqual({ accent: "#3f9a4f", icon: "server" }); + }); +}); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 34e70e94197bd..4f3a77daf5d90 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -43,6 +43,8 @@ const CONTEXT_CHIP_PRESENTATIONS = { accent: "#009c96", icon: "messages-square", }, + // A machine: the record carries only its identity, so every machine wears one glyph. + environment: { accent: "#3f9a4f", icon: "server" }, } as const satisfies Record; const CHIP_CONTRAST = 5; diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index f6c3a0e5c5536..34b4e3fcdcdf5 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -14,6 +14,8 @@ import { type ScopedThreadRef, type ServerProviderSkill, type ThreadContextRecord, + type EnvironmentContextRecord, + type EnvironmentId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { @@ -115,6 +117,20 @@ export function threadComposerContext(ref: ScopedThreadRef, title: string): Thre }; } +/** Same identity as web: one record per machine, so mentioning it again reuses the chip. */ +export function environmentComposerContext( + environmentId: EnvironmentId, + label: string, +): EnvironmentContextRecord { + return { + version: 1, + kind: "environment", + contextId: ComposerContextId.make(`environment_${environmentId}`), + label: sanitizeComposerContextLabel(label, "environment"), + environmentId, + }; +} + export function pullRequestComposerContext( pullRequest: PullRequestContextMetadata, id: string, diff --git a/apps/mobile/src/lib/contextChipIcons.generated.ts b/apps/mobile/src/lib/contextChipIcons.generated.ts index 10a8b6af70adb..809f76dca49c3 100644 --- a/apps/mobile/src/lib/contextChipIcons.generated.ts +++ b/apps/mobile/src/lib/contextChipIcons.generated.ts @@ -11,6 +11,7 @@ export const CONTEXT_CHIP_ICON_SOURCES = { "message-circle": require("../../assets/context-chip-icons/message-circle.png"), "messages-square": require("../../assets/context-chip-icons/messages-square.png"), "mouse-pointer-click": require("../../assets/context-chip-icons/mouse-pointer-click.png"), + server: require("../../assets/context-chip-icons/server.png"), skill: require("../../assets/context-chip-icons/skill.png"), terminal: require("../../assets/context-chip-icons/terminal.png"), } as const satisfies Readonly>; diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 776a615f46afb..7bfd41e1ca883 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -4,7 +4,8 @@ import { hasRelayRoute, type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems"; +import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; @@ -55,3 +56,17 @@ export function useEnvironments() { export function useEnvironmentConnectionState(environmentId: EnvironmentId) { return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); } + +/** The user's machines as `@` mention candidates, drawn with their machine kind. */ +export function useMentionableEnvironments(): ReadonlyArray { + const { environments } = useEnvironments(); + return useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + })), + [environments], + ); +} diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 5c73cc03fff9b..3dc35aca981b9 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -21,6 +21,7 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as Sqlite from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as McpOAuth from "./McpOAuth.ts"; @@ -46,7 +47,12 @@ const makeLayerRoutes = (capture: (auth: EnvironmentAuth.EnvironmentAuth["Servic ), HttpApiBuilder.layer(AuthTestApi).pipe( Layer.provide(AuthHttp.layer), - Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), + Layer.provide( + McpOAuthHttp.layer.pipe( + Layer.provide(McpOAuth.layer), + Layer.provide(ServerSettings.layerTest()), + ), + ), Layer.provide(AuthHttp.layerAuthenticatedAuth), ), ).pipe( diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 3be31570b4d44..8f664f5bc7cba 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -28,6 +28,7 @@ import * as Schema from "effect/Schema"; import { HttpServerRequest } from "effect/http"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -239,6 +240,11 @@ const make = Effect.gen(function* () { const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const linksEnabled = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.linkedEnvironmentsEnabled), + Effect.orElseSucceed(() => false), + ); const signingKey = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.orDie); @@ -317,6 +323,13 @@ const make = Effect.gen(function* () { } // RFC 7591 allows ignoring unsupported registration options; all clients use public PKCE. const peerLink = metadata.software_id === MCP_PEER_LINK_SOFTWARE_ID; + if (peerLink && !(yield* linksEnabled)) { + return yield* new AuthMcpRegistrationError({ + error: "invalid_client_metadata", + error_description: + "Linked environments is off on that machine. Turn it on in its Settings → Labs.", + }); + } return { clientId: signClientId(name, redirectUris, peerLink), name, diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts index d831bc44e828e..c8d6a2e0112ee 100644 --- a/apps/server/src/cli/environment.ts +++ b/apps/server/src/cli/environment.ts @@ -15,6 +15,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as PeerLinks from "../peer/PeerLinks.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { authLocationFlags, type CliAuthLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -41,6 +42,11 @@ const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* issuedAt: 1, }; const layerServer = McpHttpServer.layerMcpTransport.pipe( + Layer.provide(ServerSettings.layerTest()), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ resolve: (presented) => @@ -782,7 +786,9 @@ it.effect("admits provider and OAuth client credentials and points only clients ), }), ).pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe(Layer.provide(ServerSettings.layerTest())), + ), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ resolve: (token) => @@ -857,6 +863,130 @@ it.effect("admits provider and OAuth client credentials and points only clients ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("hides tools for links and refuses another environment while the beta is off", () => + Effect.scoped( + Effect.gen(function* () { + const token = "linksToken"; + const scope: McpInvocationContext.McpThreadInvocationScope = { + environmentId, + requestNamespace: token, + thread: { + threadId: ThreadId.make("thread-links"), + providerSessionId: token, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const LinksToolkit = Toolkit.make( + Tool.make("links", { + description: "Lists links.", + success: Schema.Struct({ links: Schema.Number }), + failure: OrchestratorMcpFailure, + failureMode: "return", + }).annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff), + Tool.make("where", { + description: "Says where it ran.", + parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget }), + success: Schema.Struct({ ranIn: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return", + }), + ); + const settingsContext = yield* Layer.build(ServerSettings.layerTest()); + const settings = Context.get(settingsContext, ServerSettings.ServerSettingsService); + const serverLayer = McpHttpServer.toolkitRegistration( + LinksToolkit, + McpToolAccess.toLayer(LinksToolkit, { + links: McpToolAccess.reads(() => Effect.succeed({ links: 0 })), + where: McpToolAccess.reads(() => Effect.succeed({ ranIn: "here" })), + }), + ).pipe( + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe( + Layer.provide(Layer.succeedContext(settingsContext)), + ), + ), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (presented) => Effect.succeed(presented === token ? scope : undefined), + }), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + const rpc = (method: string, params: object) => + httpClient + .post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + JSON.stringify({ jsonrpc: "2.0", id: 2, method, params }), + "application/json", + ), + }) + .pipe(Effect.flatMap((response) => response.json)); + const listed = () => + rpc("tools/list", {}).pipe( + Effect.map((body) => + (body as { result: { tools: ReadonlyArray<{ name: string }> } }).result.tools.map( + (tool) => tool.name, + ), + ), + ); + const ranIn = (where: EnvironmentId) => + rpc("tools/call", { name: "where", arguments: { environmentId: where } }).pipe( + Effect.map( + (body) => + ( + body as { + result: { + isError?: boolean; + structuredContent?: unknown; + content: ReadonlyArray<{ text: string }>; + }; + } + ).result, + ), + ); + const elsewhere = EnvironmentId.make("environment-elsewhere"); + + expect(yield* listed()).toEqual(["where"]); + expect(yield* ranIn(elsewhere)).toMatchObject({ + isError: true, + content: [{ text: expect.stringContaining("Linked environments is off on this machine") }], + }); + expect((yield* ranIn(environmentId)).structuredContent).toEqual({ ranIn: "here" }); + + yield* settings.updateSettings({ linkedEnvironmentsEnabled: true }); + expect(yield* listed()).toEqual(["links", "where"]); + // On, the call goes on to the forwarding, which this server lacks. + expect(yield* ranIn(elsewhere)).toMatchObject({ + isError: true, + content: [{ text: expect.stringContaining("cannot reach linked environments") }], + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("lists pull request watch tools only to threads that are not delegated children", () => Effect.scoped( Effect.gen(function* () { @@ -881,7 +1011,9 @@ it.effect("lists pull request watch tools only to threads that are not delegated ["childToken", scopeFor("childToken", true)], ]); const serverLayer = McpHttpServer.layerPullRequestsToolkit.pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe(Layer.provide(ServerSettings.layerTest())), + ), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ resolve: (token) => Effect.succeed(scopes.get(token)), @@ -986,7 +1118,9 @@ it.effect( ), }), ).pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe(Layer.provide(ServerSettings.layerTest())), + ), Layer.provide(McpToolAccessTestkit.liveThreadsLayer), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 4d3805809df3d..1173b4cdcade7 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -24,6 +24,7 @@ import { import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as HtmlRender from "../htmlRender/HtmlRender.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; @@ -181,9 +182,23 @@ const withModeLimit = ( : { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } }; }; +// The Linked environments beta is off here: links wait until it is on again. +const linkedEnvironmentsOff = HttpServerResponse.jsonUnsafe( + { + error: "linked_environments_off", + message: "Linked environments is off on this machine. Turn it on in its Settings → Labs.", + }, + { status: 403, headers: { "cache-control": "no-store" } }, +); + const makeMcpAuthMiddleware = Effect.gen(function* () { const registry = yield* McpSessionRegistry.McpSessionRegistry; const clients = yield* Effect.serviceOption(McpClientAuthenticator); + const serverSettings = yield* ServerSettings.ServerSettingsService; + const linksEnabled = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.linkedEnvironmentsEnabled), + Effect.orElseSucceed(() => false), + ); return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { const request = yield* HttpServerRequest.HttpServerRequest; const authorization = request.headers.authorization; @@ -208,8 +223,13 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { } const limited = withModeLimit(invocation, request); if (limited === undefined) return invalidModeLimit; + const linkedEnvironments = yield* linksEnabled; + if (limited.client?.linked === true && !linkedEnvironments) return linkedEnvironmentsOff; return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, limited), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...limited, + linkedEnvironments, + }), Effect.map(normalizeMcpHttpResponse), ); }) satisfies McpAuthMiddleware; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 8e0f55f36b9e9..26795c51696bd 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -101,6 +101,8 @@ export interface McpInvocationScope { readonly requestNamespace: string; readonly thread: McpThreadCaller | undefined; readonly client: McpClientCaller | undefined; + /** False while the Linked environments beta is off here; `/mcp` reads it per request. */ + readonly linkedEnvironments?: boolean; } export class McpInvocationContext extends Context.Service< @@ -118,6 +120,13 @@ export const hiddenFromSubagents = (): boolean => { return scope?.thread?.subagent !== true; }; +/** `McpSchema.EnabledWhen` for tools that only serve links, hidden while the beta is off. */ +export const hiddenWhileLinksOff = (): boolean => { + const fiber = Fiber.getCurrent(); + const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext); + return scope?.linkedEnvironments !== false; +}; + export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index a36b58a2b2d38..b82d9bb5ae59c 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -399,6 +399,7 @@ const routed = Effect.fn("McpToolAccess.routed")(function* )(params); if (Option.isNone(forwarding)) return yield* linksUnavailable; return yield* forwarding.value.call(scope, tool, target, { ...params, environmentId }); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 859004d9ce66d..10cf6e2106efc 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -38,7 +38,7 @@ import { ThreadMetadataMcpUpdateResult, TrimmedNonEmptyString, } from "@t3tools/contracts"; -import { Tool, Toolkit } from "effect/ai"; +import { McpSchema, Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -316,7 +316,8 @@ const EnvironmentLinksTool = Tool.make("t3_environment_links", { .annotate(Tool.Title, "List linked environments") .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false) - .annotate(Tool.Idempotent, true); + .annotate(Tool.Idempotent, true) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff); const EnvironmentLinkTool = Tool.make("t3_environment_link", { description: @@ -329,7 +330,8 @@ const EnvironmentLinkTool = Tool.make("t3_environment_link", { }) .annotate(Tool.Title, "Link another environment") .annotate(Tool.Destructive, false) - .annotate(Tool.OpenWorld, true); + .annotate(Tool.OpenWorld, true) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff); const EnvironmentUnlinkTool = Tool.make("t3_environment_unlink", { description: @@ -342,7 +344,8 @@ const EnvironmentUnlinkTool = Tool.make("t3_environment_unlink", { }) .annotate(Tool.Title, "Forget a linked environment") .annotate(Tool.Destructive, true) - .annotate(Tool.Idempotent, true); + .annotate(Tool.Idempotent, true) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff); export const OrchestratorToolkit = Toolkit.make( OrchestratorCapabilitiesTool, diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index ddcf3d0fa06a5..b8391e285c18f 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -80,6 +80,13 @@ export class PeerForwarding extends Context.Service< } >()("t3/peer/PeerForwarding") {} +/** What an agent hears when it names another machine while the beta is off here. */ +export const linkedEnvironmentsOff = new OrchestratorMcpFailure({ + code: "capability_denied", + message: + "Linked environments is off on this machine, so no tool can act on another machine. Leave out environmentId to work here; the user can turn it on in Settings → Labs.", +}); + /** The linked environment a call names, or nothing when it names this one. */ export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefined) => environmentId === scope.environmentId ? undefined : environmentId; @@ -204,14 +211,25 @@ const make = Effect.gen(function* () { } }); - const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => - tool.name === "t3_thread_wait" + // While the beta is off, agents start nothing over links. Tasks already running there keep + // following through readThread, waitForThread and interruptThread, so they still finish. + const requireEnabled = links.enabled.pipe( + Effect.flatMap((enabled) => (enabled ? Effect.void : Effect.fail(linkedEnvironmentsOff))), + ); + + const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => { + const forwarded: Effect.Effect< + Tool.Success, + OrchestratorMcpFailure + > = tool.name === "t3_thread_wait" ? (waitForThread( scope, environmentId, params as OrchestratorMcpThreadWaitInput, ) as Effect.Effect, OrchestratorMcpFailure>) : forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params); + return Effect.andThen(requireEnabled, forwarded); + }; /** * The next step for an agent whose machine is not linked: without it, an @@ -223,6 +241,7 @@ const make = Effect.gen(function* () { const listLinks: PeerForwarding["Service"]["links"] = Effect.fn("PeerForwarding.listLinks")( function* (scope) { yield* callerLimits(scope); + yield* requireEnabled; const listed = yield* links.list.pipe( Effect.mapError( (error) => diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index 12a96831d3dec..cdae666138355 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -143,6 +143,12 @@ const make = Effect.gen(function* () { "t3_environment_link", ); const threadId = threadScope.thread.threadId; + if (!(yield* peerLinks.enabled)) { + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: PeerLinks.LINKED_ENVIRONMENTS_OFF, + }); + } const unreadable = (error: unknown) => new OrchestratorMcpFailure({ code: "orchestration_error", diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 2d6a0c267e341..f16a91f26c0ad 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -24,6 +24,7 @@ import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as McpToolAccess from "../mcp/McpToolAccess.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { descriptorOf, layerLinkingEnvironment, @@ -84,6 +85,7 @@ const makeA = ( links: Context.get(context, PeerLinks.PeerLinks), peer: Context.get(context, PeerMcpClient.PeerMcpClient), sql: Context.get(context, SqlClient.SqlClient), + settings: Context.get(context, ServerSettings.ServerSettingsService), })), ); @@ -155,6 +157,43 @@ it.effect("links with a pairing code, and the peer holds its agents to the limit ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect( + "each machine's Linked environments beta gates links, and a link waits while it is off", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + const link = a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + + yield* a.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + expect(yield* link.pipe(Effect.flip)).toMatchObject({ + reason: "disabled", + message: PeerLinks.LINKED_ENVIRONMENTS_OFF, + }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + + yield* a.settings.updateSettings({ linkedEnvironmentsEnabled: true }); + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + const refusedThere = yield* link.pipe(Effect.flip); + expect(refusedThere.reason).toBe("pairing_rejected"); + expect(refusedThere.message).toContain("Linked environments is off on that machine"); + expect(yield* linkedSessions(b)).toEqual([]); + + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: true }); + yield* link; + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + // Not final, so a task already running there picks up again once it is back on. + const waiting = yield* probe(a).pipe(Effect.flip); + expect(waiting.code).toBe("orchestration_error"); + expect(waiting.message).toContain("has Linked environments turned off"); + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: true }); + expect(yield* probe(a)).toEqual({ caller: "T3 Code · Laptop", modes: "auto/default" }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("a link whose row cannot be stored keeps none of its token", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 6c41af9e3634a..0f7e39355b95d 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -28,6 +28,7 @@ import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as Sqlite from "../persistence/Sqlite.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as PeerMcpClient from "./PeerMcpClient.ts"; class PeerTestApi extends HttpApi.make("environment") @@ -70,6 +71,7 @@ export const servePeer = ( getDescriptor: Ref.get(descriptor), }); const authContext = yield* EnvironmentAuth.layer.pipe( + Layer.provideMerge(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })), Layer.provide(Sqlite.layerMemory), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(ServerEnvironment.layerIdentity), @@ -127,6 +129,8 @@ export const servePeer = ( return { url, auth, + /** Its settings, as for turning its Linked environments beta off. */ + settings: Context.get(authContext, ServerSettings.ServerSettingsService), descriptor, bearers, /** How often its descriptor was asked for, as a probe of whether it answers. */ @@ -154,6 +158,7 @@ export const layerLinkingEnvironment = ( ) => PeerMcpClient.layer.pipe( Layer.provideMerge(PeerLinks.layer), + Layer.provideMerge(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })), Layer.provideMerge(Sqlite.layerMemory), Layer.provide(Layer.mergeAll(secrets, FetchHttpClient.layer)), // Services that act for this environment, such as delegation, name it too. diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 04b59b677e96f..f47ea7d006a36 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -31,6 +31,7 @@ import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassifi import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; /** The redirect registered for a link. Nothing ever listens there: the code comes back in the response. */ const LINK_REDIRECT_URI = "http://127.0.0.1/t3code-peer-link"; @@ -49,6 +50,9 @@ export interface ResolvedPeerLink { readonly token: string; } +export const LINKED_ENVIRONMENTS_OFF = + "Linked environments is off on this machine. Turn it on in Settings → Labs."; + /** * Links from this environment to others. Linking signs this environment in to * the peer's `/mcp` as an outside agent with a pairing code from the peer, the @@ -58,6 +62,8 @@ export interface ResolvedPeerLink { export class PeerLinks extends Context.Service< PeerLinks, { + /** Whether the Linked environments beta (Settings → Labs) is on here. */ + readonly enabled: Effect.Effect; readonly link: (input: PeerLinkCreateInput) => Effect.Effect; /** * The environment answering at `url`, or nothing when it is not a usable @@ -159,6 +165,11 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const httpClient = yield* HttpClient.HttpClient; const environment = yield* ServerEnvironment.ServerEnvironment; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const enabled = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.linkedEnvironmentsEnabled), + Effect.orElseSucceed(() => false), + ); /** A fresh name for one link's token; the row records it. */ const newSecretName = crypto.randomBytes(16).pipe( @@ -388,6 +399,9 @@ const make = Effect.gen(function* () { }); const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) { + if (!(yield* enabled)) { + return yield* new PeerLinkError({ reason: "disabled", message: LINKED_ENVIRONMENTS_OFF }); + } const urls = [input.url, ...(input.alternateUrls ?? [])]; // The user picked a machine: link through the addresses that prove to // be it, so a reused LAN address never receives the pairing code. @@ -591,6 +605,7 @@ const make = Effect.gen(function* () { ); return PeerLinks.of({ + enabled, link, describe: describeUrl, list, diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index e85e39aebfd68..b953d458f8e27 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -76,6 +76,13 @@ const fromLinkError = (error: PeerLinkError) => message: error.message, }); +/** Not final, so a task already running there resumes once the beta is on there again. */ +const linkedEnvironmentsOffThere = new OrchestratorMcpFailure({ + code: "orchestration_error", + message: + "The linked environment has Linked environments turned off. Turn it on in its Settings → Labs.", +}); + /** The body of an MCP response: JSON, or a single server-sent event carrying JSON. */ const responseJson = (contentType: string | undefined, text: string) => contentType?.includes("text/event-stream") === true @@ -126,6 +133,7 @@ const make = Effect.gen(function* () { limitHeader, ); const sessionId = initialized.headers["mcp-session-id"]; + if (initialized.status === 403) return yield* linkedEnvironmentsOffThere; if (initialized.status !== 200 || sessionId === undefined) { return yield* new OrchestratorMcpFailure({ code: initialized.status === 401 ? "capability_denied" : "orchestration_error", @@ -182,6 +190,7 @@ const make = Effect.gen(function* () { "The linked environment no longer accepts this link. It may have been revoked there; link it again.", }); } + if (response.status === 403) return yield* linkedEnvironmentsOffThere; if (response.status !== 200) { return yield* new OrchestratorMcpFailure({ code: "orchestration_error", diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 5d3661ba1fce0..73c547546d1d6 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -603,6 +603,7 @@ const makeLaptop = ( const server = Context.get(here, McpServer.McpServer); return { links: Context.get(linking, PeerLinks.PeerLinks), + settings: Context.get(linking, ServerSettings.ServerSettingsService), orchestrator: Context.get(here, Orchestrator.OrchestratorV2), remote: Context.get(here, RemoteDelegation.RemoteDelegation), threads: Context.get(here, ThreadManagement.ThreadManagementService), @@ -843,6 +844,52 @@ it.effect("a task delegated to the box wakes the laptop's parent when it ends th ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect( + "with the beta off here, a task already running there still ends, and no new one starts", + () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + + yield* a.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + const refused = yield* a.call("delegate_task", { + task: "Run the linter on the box.", + target: { + environmentId: box.environmentId, + providerInstanceId: instanceId, + model: "gpt-5.4", + }, + mode: "async", + clientRequestId: "lint-on-box", + }); + expect(refused.isError).toBe(true); + expect(JSON.stringify(refused.content)).toContain( + PeerForwarding.linkedEnvironmentsOff.message, + ); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toHaveLength( + 1, + ); + + yield* Ref.set(thread.reply, "All 412 tests pass on the box."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "All 412 tests pass on the box.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("a task named only by environment inherits the parent's model, within the link", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 363e5db0b6071..9ec467e81885a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1014,6 +1014,7 @@ describe("snapshotComposerSend", () => { previewAnnotations: [], reviewComments: [], threadContexts: [], + environmentContexts: [], }); expect(snapshot.terminalContexts).toEqual([live]); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0e36a3ef4681e..24fe3c614516c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -39,6 +39,7 @@ import { type ScopedProjectRef, type ScopedThreadRef, type ThreadContextRecord, + type EnvironmentContextRecord, type ThreadId, type ThreadLinkedPullRequest, type RunId, @@ -1195,6 +1196,7 @@ export interface PlanFollowUpComposerSnapshot { readonly reviewComments: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } export function restorePlanFollowUpComposer(input: { @@ -1204,6 +1206,7 @@ export function restorePlanFollowUpComposer(input: { readonly writeReviewComments: (comments: ReadonlyArray) => void; readonly writePreviewAnnotations: (annotations: ReadonlyArray) => void; readonly writeThreadContexts: (records: ReadonlyArray) => void; + readonly writeEnvironmentContexts: (records: ReadonlyArray) => void; readonly resetCursor: (options: { cursor: number; prompt: string; @@ -1215,6 +1218,7 @@ export function restorePlanFollowUpComposer(input: { input.writeReviewComments(input.snapshot.reviewComments); input.writePreviewAnnotations(input.snapshot.previewAnnotations); input.writeThreadContexts(input.snapshot.threadContexts); + input.writeEnvironmentContexts(input.snapshot.environmentContexts); input.resetCursor({ cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), prompt: input.snapshot.prompt, @@ -1374,6 +1378,7 @@ export interface ComposerSendContents { readonly previewAnnotations: ReadonlyArray; readonly reviewComments: ReadonlyArray; readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } /** @@ -1393,6 +1398,7 @@ export function snapshotComposerSend(contents: ComposerSendContents) { previewAnnotations: [...contents.previewAnnotations], reviewComments: [...contents.reviewComments], threadContexts: [...contents.threadContexts], + environmentContexts: [...contents.environmentContexts], messageText: contents.terminalContexts .filter((context) => !terminalContexts.includes(context)) .reduce( diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index b6abe578770c8..fcd4c9e4fed30 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -27,6 +27,7 @@ const contextChipVariants = cva( "pr-closed": "[--context-chip-accent:var(--context-chip-pr-closed)]", skill: "[--context-chip-accent:var(--context-chip-skill)]", thread: "[--context-chip-accent:var(--context-chip-thread)]", + environment: "[--context-chip-accent:var(--context-chip-environment)]", citation: "[--context-chip-accent:var(--context-chip-citation)]", "doc-pdf": "[--context-chip-accent:var(--context-chip-doc-pdf)]", "doc-word": "[--context-chip-accent:var(--context-chip-doc-word)]", diff --git a/apps/web/src/components/EnvironmentContextChip.tsx b/apps/web/src/components/EnvironmentContextChip.tsx new file mode 100644 index 0000000000000..9730fc7c342c9 --- /dev/null +++ b/apps/web/src/components/EnvironmentContextChip.tsx @@ -0,0 +1,40 @@ +import { type EnvironmentContextRecord, resolveEnvironmentMachineKind } from "@t3tools/contracts"; + +import { useEnvironment } from "~/state/environments"; +import { ContextChip, ContextChipLabel } from "./ContextChip"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Inline chip for a mentioned machine, in the composer and in sent messages. Prefers the + * name this client knows the machine by, and draws it with its machine icon. + */ +export function EnvironmentContextChip(props: { + record: Pick; + copyMarkdown?: string; +}) { + const environment = useEnvironment(props.record.environmentId); + const label = environment?.label.trim() || props.record.label; + return ( + + + + {label} + + } + /> + + {environment ? "T3 Code environment" : "Machine not in this app"} + + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 26761d92d8fe4..1d17f03e41b67 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import type { PreviewAnnotationPayload, ProviderApprovalDecision, ThreadContextRecord, + EnvironmentContextRecord, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, @@ -169,6 +170,8 @@ import { terminalContextRecord, threadContextRecord, threadContextReference, + environmentContextRecord, + environmentContextReference, } from "~/lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; @@ -336,6 +339,7 @@ export interface ChatComposerHandle { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; threadContexts: ThreadContextRecord[]; + environmentContexts: EnvironmentContextRecord[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -721,6 +725,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; const composerThreadContexts = attachmentDraft.threadContexts; + const composerEnvironmentContexts = composerDraft.environmentContexts; const { pendingSnapShotAnimations, pendingSnapShotIds, @@ -772,6 +777,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) reviewComments: composerReviewComments, previewAnnotations: composerPreviewAnnotations, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, images: composerImages, files: composerFiles, uploadsByImageId, @@ -1153,6 +1159,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, + questionAttachmentTarget, }); const composerMenuOpen = Boolean(composerTrigger); @@ -1304,6 +1311,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); + const addComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.addEnvironmentContexts, + ); + const setComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.setEnvironmentContexts, + ); const buildContextClipboardFragment = (contextIds: ReadonlyArray): string | null => { const wanted = new Set(contextIds); const copiedFiles = new Map(); @@ -1320,6 +1333,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) .filter((c) => wanted.has(terminalContextReference(c).contextId)) .map(terminalContextRecord), ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), + ...composerEnvironmentContexts.filter((record) => wanted.has(record.contextId)), ...composerReviewComments .filter((c) => wanted.has(reviewCommentContextId(c.id))) .map(reviewCommentContextRecord), @@ -1693,6 +1707,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerDraftThreadContexts(composerDraftTarget, nextThreads); } + const liveEnvironmentIds = new Set( + composerEnvironmentContexts.map((record) => record.contextId), + ); + const restoredEnvironments = [...referenced].flatMap((contextId) => { + if (liveEnvironmentIds.has(contextId)) return []; + const record = retained.environments.get(contextId); + return record ? [record] : []; + }); + const nextEnvironments = [ + ...composerEnvironmentContexts.filter((record) => referenced.has(record.contextId)), + ...restoredEnvironments, + ]; + for (const record of composerEnvironmentContexts) { + if (!referenced.has(record.contextId)) retained.environments.set(record.contextId, record); + } + if ( + nextEnvironments.length !== composerEnvironmentContexts.length || + restoredEnvironments.length > 0 + ) { + setComposerDraftEnvironmentContexts(composerDraftTarget, nextEnvironments); + } + for (const comment of composerReviewComments) { const contextId = reviewCommentContextId(comment.id); if (!referenced.has(contextId)) { @@ -1991,6 +2027,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "environment") { + if (trigger.kind !== "path") return; + const record = environmentContextRecord(item.environmentId, item.label); + const replacement = `${formatInlineContextReference(environmentContextReference(record))} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + addComposerDraftEnvironmentContexts(composerDraftTarget, [record], { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } }; const onSelectComposerItemStable = useStableCallback(onSelectComposerItem); @@ -3134,6 +3190,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -3189,6 +3246,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerPreviewAnnotations, composerReviewComments, + composerThreadContexts, + composerEnvironmentContexts, focusComposer, environmentId, primaryEnvironmentId, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 4d556d48be701..d2f224b3f5bec 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -5,6 +5,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import { + type EnvironmentId, + type EnvironmentMachineKind, type ProjectEntry, type ProviderDriverKind, type PullRequestContextMetadata, @@ -27,6 +29,7 @@ import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../compo import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { ComposerBanner } from "./ComposerBanner"; import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; @@ -77,6 +80,14 @@ export type ComposerCommandItem = thread: ScopedThreadRef; label: string; description: string; + } + | { + id: string; + type: "environment"; + environmentId: EnvironmentId; + machine: EnvironmentMachineKind; + label: string; + description: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -202,6 +213,13 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "thread" ? (