# Files changed · PR \#16698 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/16698/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(web,mobile\): linked environments in Connections](https://git.cafe/pingdotgg/t3code/pull/16698.md)

Version: 32

Head commit: f580fefde9a4e31cfd5ff263ea43b0d392601b31

Base commit: e7be3191310fe8c8ee8493338a2e98665a999d4d

Comparison base: e7be3191310fe8c8ee8493338a2e98665a999d4d

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx

Status: modified; +10; −0

```diff
@@ -11,6 +11,7 @@
 import { useRemoteConnections } from "../../state/use-remote-environment-registry";
 import { LocalEnvironmentList } from "./LocalEnvironmentList";
 import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
+import { LinkedEnvironmentsSection } from "./LinkedEnvironmentsSection";
 
 export function ConnectionsRouteScreen() {
   const {
@@ -69,6 +70,15 @@
           onSetEnabled={onSetEnvironmentEnabled}
           onUpdate={onUpdateEnvironment}
         />
+        {connectedEnvironments
+          .filter((environment) => environment.connectionState === "connected")
+          .map((environment) => (
+            <LinkedEnvironmentsSection
+              key={environment.environmentId}
+              environmentId={environment.environmentId}
+              environmentLabel={environment.environmentLabel}
+            />
+          ))}
         <GitHubRoutingSettings />
       </ScrollView>
     </View>
```

## apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx

Status: added; +78; −0

```diff
@@ -1,0 +1,78 @@
+import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks";
+import { useFocusEffect } from "@react-navigation/native";
+import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import { useCallback, useState } from "react";
+import { AppState, View } from "react-native";
+
+import { AppText as Text } from "../../components/ui/AppText";
+import { useEnvironmentServerConfig } from "../../state/entities";
+import { peerLinkEnvironment } from "../../state/peerLinks";
+import { useEnvironmentQuery } from "../../state/query";
+import { useEnvironmentScope } from "../../state/session";
+import { SettingsSection } from "../settings/components/SettingsSection";
+
+export function LinkedEnvironmentsSection(props: {
+  readonly environmentId: EnvironmentId;
+  readonly environmentLabel: string;
+}) {
+  const config = useEnvironmentServerConfig(props.environmentId);
+  const canRead = useEnvironmentScope(props.environmentId, AuthAccessReadScope);
+  const supported = canRead && config?.environment.capabilities.peerLinks === true;
+  const links = useEnvironmentQuery(
+    supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null,
+  );
+  const [nowMs, setNowMs] = useState(Date.now);
+  const hasLinks = supported && (links.data?.links.length ?? 0) > 0;
+  useFocusEffect(
+    useCallback(() => {
+      if (!hasLinks) return;
+      const updateNow = () => setNowMs(Date.now());
+      updateNow();
+      const timer = setInterval(updateNow, 60_000);
+      const subscription = AppState.addEventListener("change", (state) => {
+        if (state === "active") updateNow();
+      });
+      return () => {
+        clearInterval(timer);
+        subscription.remove();
+      };
+    }, [hasLinks]),
+  );
+  if (!supported || links.error || !links.data || links.data.links.length === 0) return null;
+  const now = DateTime.makeUnsafe(nowMs);
+  return (
+    <View className="mt-5 gap-3">
+      <SettingsSection title={`Linked from ${props.environmentLabel}`}>
+        {links.data.links.map((link) => {
+          const health = peerLinkHealth(link, now);
+          const status =
+            health.kind === "expired"
+              ? "Expired. Link it again with a new pairing code."
+              : health.kind === "unreachable"
+                ? (health.detail ?? "Not answering right now.")
+                : health.kind === "failing"
+                  ? health.detail
+                  : health.expiresInDays !== null
+                    ? `Expires in ${health.expiresInDays} ${health.expiresInDays === 1 ? "day" : "days"}`
+                    : "Reachable";
+          return (
+            <View key={link.environmentId} className="gap-0.5 p-4">
+              <Text className="text-base font-t3-bold text-foreground">{link.label}</Text>
+              <Text
+                className={
+                  health.kind === "reachable" && health.expiresInDays === null
+                    ? "text-xs text-foreground-muted"
+                    : "text-xs text-danger-foreground"
+                }
+                numberOfLines={2}
+              >
+                {status}
+              </Text>
+            </View>
+          );
+        })}
+      </SettingsSection>
+    </View>
+  );
+}
```

## apps/mobile/src/features/threads/ThreadRouteScreen.tsx

Status: modified; +1; −0

```diff
@@ -425,6 +425,7 @@
   const headerSubtitle = [
     selectedThreadProject?.title ?? null,
     selectedEnvironmentConnection?.environmentLabel ?? null,
+    selectedThread?.linkOrigin ? `From ${selectedThread.linkOrigin.label}` : null,
   ]
     .filter(Boolean)
     .join(" · ");
```

## apps/mobile/src/state/peerLinks.ts

Status: added; +5; −0

```diff
@@ -1,0 +1,5 @@
+import { createPeerLinkEnvironmentAtoms } from "@t3tools/client-runtime/state/peerLinks";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const peerLinkEnvironment = createPeerLinkEnvironmentAtoms(connectionAtomRuntime);
```

## apps/server/src/auth/RpcAuthorization.ts

Status: modified; +0; −3

```diff
@@ -5,7 +5,6 @@
   authScopeRequiredResponse,
   AssetCreateUrlInput,
   AuthAccessReadScope,
-  AuthAccessWriteScope,
   ServerSettingsPatch,
   ProviderInstanceMutation,
   requiredScopesForServerSettingsPatch,
@@ -85,8 +84,6 @@
   [WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope,
   [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
   [WS_METHODS.peerLinksList]: AuthAccessReadScope,
-  [WS_METHODS.peerLinksLink]: AuthAccessWriteScope,
-  [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope,
   [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
   [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
   [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope,
```

## apps/web/src/components/ChatView.tsx

Status: modified; +1; −0

```diff
@@ -3194,6 +3194,7 @@
             activeThreadId={activeThread.id}
             isServerThread={isServerThread}
             activeThreadTitle={activeThread.title}
+            activeThreadLinkOrigin={activeThreadShell?.linkOrigin?.label ?? null}
             activeProject={activeProject ?? null}
             parentThreadLink={parentThreadLink}
             onOpenThread={onOpenRelatedThreadStable}
```

## apps/web/src/components/auth/ConnectAgentSurface.tsx

Status: modified; +2; −2

```diff
@@ -24,7 +24,7 @@
 import { Spinner } from "../ui/spinner";
 import { AuthSurfaceShell } from "./AuthSurfaceShell";
 
-const accessConfig: Record<
+export const accessConfig: Record<
   AuthMcpClientAccess,
   { readonly label: string; readonly description: string; readonly icon: LucideIcon }
 > = {
@@ -281,7 +281,7 @@
   );
 }
 
-function AccessOption({
+export function AccessOption({
   access,
   selected,
 }: {
```

## apps/web/src/components/chat/ChatHeader.tsx

Status: modified; +13; −0

```diff
@@ -19,6 +19,7 @@
   type MouseEvent as ReactMouseEvent,
 } from "react";
 import { isTrailingDoubleClick } from "../Sidebar.logic";
+import { Badge } from "../ui/badge";
 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
 import { toastManager } from "../ui/toast";
 import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
@@ -42,6 +43,7 @@
   activeThreadEnvironmentId: EnvironmentId;
   activeThreadId: ThreadId;
   activeThreadTitle: string;
+  activeThreadLinkOrigin?: string | null | undefined;
   isServerThread: boolean;
   activeProject: EnvironmentProject | null;
   parentThreadLink: { threadId: ThreadId; title: string } | null;
@@ -70,6 +72,7 @@
   activeThreadEnvironmentId,
   activeThreadId,
   activeThreadTitle,
+  activeThreadLinkOrigin,
   isServerThread,
   activeProject,
   parentThreadLink,
@@ -404,6 +407,16 @@
               <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
             </Tooltip>
           )}
+          {renamingTitle === null && activeThreadLinkOrigin ? (
+            <Tooltip>
+              <TooltipTrigger render={<Badge variant="info" size="sm" />}>
+                From {activeThreadLinkOrigin}
+              </TooltipTrigger>
+              <TooltipPopup side="top">
+                Started by an agent on {activeThreadLinkOrigin} through a linked environment.
+              </TooltipPopup>
+            </Tooltip>
+          ) : null}
         </WorkspaceBreadcrumbItem>
       </WorkspaceBreadcrumb>
     </div>
```

## apps/web/src/components/settings/ConnectionsSettings.tsx

Status: modified; +11; −0

```diff
@@ -36,6 +36,8 @@
 import { searchableSetting } from "./settingsSearch";
 import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
 import { FoldedSettingsSection } from "./FoldedSettingsSection";
+import { LinkedEnvironmentsSettings } from "./LinkedEnvironmentsSettings";
+import { usePeerLinkHosts } from "~/state/peerLinks";
 import { LoadBalancingSettings } from "./LoadBalancingSettings";
 import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
 import { Input } from "../ui/input";
@@ -157,6 +159,7 @@
   const desktopBridge = window.desktopBridge;
   const { environments } = useEnvironments();
   const primaryEnvironment = usePrimaryEnvironment();
+  const peerLinkHosts = usePeerLinkHosts();
   const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });
   const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {
     reportFailure: false,
@@ -1532,6 +1535,14 @@
           }}
         />
       ) : null}
+      {peerLinkHosts.map((environment, index) => (
+        <LinkedEnvironmentsSettings
+          key={environment.environmentId}
+          environmentId={environment.environmentId}
+          environmentLabel={peerLinkHosts.length > 1 ? environment.label : null}
+          isSearchTarget={index === 0}
+        />
+      ))}
       <LoadBalancingSettings environments={loadBalancingEnvironments} />
       <GitHubRoutingSettings environments={loadBalancingEnvironments} />
     </SettingsPageContainer>
```

## apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx

Status: added; +282; −0

```diff
@@ -1,0 +1,282 @@
+import { useAtomValue } from "@effect/atom-react";
+import { AuthMcpClientAccess, type EnvironmentId, type PeerLinkSummary } from "@t3tools/contracts";
+import {
+  isAtomCommandInterrupted,
+  squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks";
+import * as DateTime from "effect/DateTime";
+import { PlusIcon } from "lucide-react";
+import { useId, useState } from "react";
+
+import { requestConfirmDialog } from "~/confirmDialog";
+import { peerLinkEnvironment } from "~/state/peerLinks";
+import { useEnvironmentQuery } from "~/state/query";
+import { useAtomCommand } from "~/state/use-atom-command";
+import { AccessOption, accessConfig } from "../auth/ConnectAgentSurface";
+import { ConnectionStatusDot } from "../ConnectionStatusDot";
+import { Button } from "../ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogPanel,
+  DialogPopup,
+  DialogTitle,
+  DialogTrigger,
+} from "../ui/dialog";
+import { Input } from "../ui/input";
+import { RadioGroup } from "../ui/radio-group";
+import { stackedThreadToast, toastManager } from "../ui/toast";
+import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
+import { SettingsRow, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
+import { searchableSetting } from "./settingsSearch";
+
+const failureToast = (title: string, description: string) =>
+  toastManager.add(stackedThreadToast({ type: "error", title, description }));
+
+export function LinkedEnvironmentsSettings({
+  environmentId,
+  environmentLabel,
+  isSearchTarget,
+}: {
+  readonly environmentId: EnvironmentId;
+  readonly environmentLabel: string | null;
+  readonly isSearchTarget: boolean;
+}) {
+  const links = useEnvironmentQuery(peerLinkEnvironment.list({ environmentId, input: {} }));
+  const canLink = useAtomValue(peerLinkEnvironment.link.permissionAtom(environmentId));
+  const search = searchableSetting("linked-environments");
+  return (
+    <SettingsSection
+      {...(isSearchTarget ? { id: search.id } : {})}
+      title={environmentLabel === null ? search.title : `${search.title} · ${environmentLabel}`}
+      headerAction={canLink ? <LinkEnvironmentAction environmentId={environmentId} /> : null}
+    >
+      {links.error ? (
+        <SettingsRow title="Could not load linked environments" description={links.error} />
+      ) : !links.data ? (
+        <SettingsRow title="Loading linked environments…" role="status" />
+      ) : links.data.links.length === 0 ? (
+        <SettingsRow
+          title="No linked environments"
+          description="Link another machine running T3 Code, such as a VPS, so agents here can launch and drive threads there."
+        />
+      ) : (
+        links.data.links.map((link) => (
+          <LinkedEnvironmentRow
+            key={link.environmentId}
+            environmentId={environmentId}
+            link={link}
+          />
+        ))
+      )}
+    </SettingsSection>
+  );
+}
+
+function LinkedEnvironmentRow({
+  environmentId,
+  link,
+}: {
+  readonly environmentId: EnvironmentId;
+  readonly link: PeerLinkSummary;
+}) {
+  const nowMs = useRelativeTimeTick(60_000);
+  const health = peerLinkHealth(link, DateTime.makeUnsafe(nowMs));
+  const unlink = useAtomCommand(peerLinkEnvironment.unlink, { label: "unlink environment" });
+  const canUnlink = useAtomValue(peerLinkEnvironment.unlink.permissionAtom(environmentId));
+  const [busy, setBusy] = useState(false);
+  const forget = async () => {
+    const confirmed = await (requestConfirmDialog(
+      `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Connections to end the session there too.`,
+      { variant: "destructive" },
+    ) ?? Promise.resolve(false));
+    if (!confirmed) return;
+    setBusy(true);
+    const result = await unlink({ environmentId, input: { environmentId: link.environmentId } });
+    setBusy(false);
+    if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+      failureToast("Could not forget the link", String(squashAtomCommandFailure(result)));
+    }
+  };
+  const status =
+    health.kind === "expired"
+      ? "Expired. Link it again with a new pairing code."
+      : health.kind === "unreachable"
+        ? (health.detail ?? "Not answering right now.")
+        : health.kind === "failing"
+          ? health.detail
+          : health.expiresInDays !== null
+            ? `Expires in ${health.expiresInDays} ${health.expiresInDays === 1 ? "day" : "days"}. Link it again to renew.`
+            : "Reachable";
+  return (
+    <div className={ITEM_ROW_CLASSNAME}>
+      <div className={ITEM_ROW_INNER_CLASSNAME}>
+        <div className="min-w-0 flex-1 space-y-1">
+          <div className="flex min-h-5 items-center gap-1.5">
+            <ConnectionStatusDot
+              tooltipText={status}
+              dotClassName={
+                health.kind === "reachable"
+                  ? health.expiresInDays === null
+                    ? "bg-success"
+                    : "bg-warning"
+                  : health.kind === "expired" || health.kind === "failing"
+                    ? "bg-destructive"
+                    : "bg-muted-foreground/30"
+              }
+              pingClassName={null}
+            />
+            <h3 className="text-sm font-medium text-foreground">{link.label}</h3>
+          </div>
+          <p className="text-xs text-muted-foreground">
+            {accessConfig[link.access].label} · {status}
+          </p>
+          <p className="truncate text-xs text-muted-foreground/80">{link.urls.join(", ")}</p>
+        </div>
+        {canUnlink ? (
+          <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
+            <Button
+              size="xs"
+              variant="destructive-outline"
+              disabled={busy}
+              onClick={() => void forget()}
+            >
+              {busy ? "Forgetting…" : "Forget"}
+            </Button>
+          </div>
+        ) : null}
+      </div>
+    </div>
+  );
+}
+
+function LinkEnvironmentAction({ environmentId }: { readonly environmentId: EnvironmentId }) {
+  const [open, setOpen] = useState(false);
+  const [url, setUrl] = useState("");
+  const [pairingCode, setPairingCode] = useState("");
+  const [access, setAccess] = useState<AuthMcpClientAccess>("approval-required");
+  const [busy, setBusy] = useState(false);
+  const formId = useId();
+  const accessLabelId = `${formId}-access`;
+  const link = useAtomCommand(peerLinkEnvironment.link, { label: "link environment" });
+  const handleOpenChange = (next: boolean) => {
+    setOpen(next);
+    if (!next) {
+      setUrl("");
+      setPairingCode("");
+      setAccess("approval-required");
+    }
+  };
+  const submit = async () => {
+    setBusy(true);
+    const result = await link({
+      environmentId,
+      input: { url: url.trim(), pairingCode: pairingCode.trim(), access },
+    });
+    setBusy(false);
+    if (result._tag === "Success") {
+      toastManager.add({ type: "success", title: `Linked ${result.value.label}` });
+      handleOpenChange(false);
+    } else if (!isAtomCommandInterrupted(result)) {
+      failureToast("Could not link the environment", String(squashAtomCommandFailure(result)));
+    }
+  };
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(next) => {
+        if (!next && busy) return;
+        handleOpenChange(next);
+      }}
+    >
+      <DialogTrigger
+        render={
+          <Button size="xs" variant="default">
+            <PlusIcon className="size-3" />
+            Link
+          </Button>
+        }
+      />
+      <DialogPopup className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>Link an environment</DialogTitle>
+          <DialogDescription>
+            Agents here can then launch, message and wait on threads there. It appears in that
+            environment's Connections, where it can be revoked.
+          </DialogDescription>
+        </DialogHeader>
+        <DialogPanel>
+          <form
+            id={formId}
+            className="space-y-4"
+            onSubmit={(event) => {
+              event.preventDefault();
+              void submit();
+            }}
+          >
+            <label className="block space-y-1.5">
+              <span className="block text-xs font-medium text-foreground">Address</span>
+              <Input
+                value={url}
+                onChange={(event) => setUrl(event.target.value)}
+                placeholder="https://box.example.ts.net"
+                disabled={busy}
+                autoFocus
+                nativeInput
+                spellCheck={false}
+              />
+            </label>
+            <label className="block space-y-1.5">
+              <span className="block text-xs font-medium text-foreground">Pairing code</span>
+              <Input
+                value={pairingCode}
+                onChange={(event) => setPairingCode(event.target.value)}
+                placeholder="From that environment's Connections, or t3 pair there"
+                disabled={busy}
+                autoComplete="one-time-code"
+                nativeInput
+                spellCheck={false}
+              />
+            </label>
+            <div className="space-y-2">
+              <span id={accessLabelId} className="block text-xs font-medium">
+                What agents here may do there
+              </span>
+              <RadioGroup
+                aria-labelledby={accessLabelId}
+                disabled={busy}
+                value={access}
+                onValueChange={(value) => setAccess(value as AuthMcpClientAccess)}
+              >
+                {AuthMcpClientAccess.literals.map((option) => (
+                  <AccessOption key={option} access={option} selected={option === access} />
+                ))}
+              </RadioGroup>
+              <p className="text-xs text-muted-foreground">
+                An agent never gets more access there than its own mode here. Below full access, it
+                can change only threads this link started, not that environment's own threads,
+                projects or settings.
+              </p>
+            </div>
+          </form>
+        </DialogPanel>
+        <DialogFooter variant="bare">
+          <DialogClose disabled={busy} render={<Button variant="outline" type="button" />}>
+            Cancel
+          </DialogClose>
+          <Button
+            form={formId}
+            type="submit"
+            disabled={busy || url.trim().length === 0 || pairingCode.trim().length === 0}
+          >
+            {busy ? "Linking…" : "Link"}
+          </Button>
+        </DialogFooter>
+      </DialogPopup>
+    </Dialog>
+  );
+}
```

## apps/web/src/components/settings/settingsSearch.test.ts

Status: modified; +27; −0

```diff
@@ -113,10 +113,37 @@
       "auto-settle-inactive-threads",
       "auto-settle-merged-threads",
       "days-before-auto-settle",
+      "linked-environments",
     ]);
     expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
   });
 
+  it("finds linked environments on a remote host without local maintenance access", () => {
+    const availability = {
+      hasCloudPublicConfig: false,
+      hasEnvironment: true,
+      hasProviderSettingsEnvironment: true,
+      hasMacProviderSettingsEnvironment: false,
+      canManageLocalBackend: false,
+      localEnvironmentDisabled: true,
+      isWslSettingsRowVisible: false,
+      hasThreadAutoSettlement: false,
+    };
+    const results = (hasPeerLinkHost: boolean) =>
+      searchSettings(
+        "linked environments",
+        filterAvailableSettingsSearchItems({ ...availability, hasPeerLinkHost }),
+      );
+    expect(results(false).map((item) => item.id)).not.toContain("linked-environments");
+    expect(results(true)).toContainEqual(
+      expect.objectContaining({
+        id: "linked-environments",
+        to: "/settings/connections",
+        targetId: "linked-environments",
+      }),
+    );
+  });
+
   it("finds keybinding commands by label, command id, and default key", () => {
     expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle");
     expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle");
```

## apps/web/src/components/settings/settingsSearch.ts

Status: modified; +12; −1

```diff
@@ -55,6 +55,7 @@
   readonly localEnvironmentOnly?: boolean;
   readonly wslAvailableOnly?: boolean;
   readonly managedTunnelOnly?: boolean;
+  readonly peerLinksOnly?: boolean;
   readonly secondary?: boolean;
   readonly requiresThreadAutoSettlement?: boolean;
 }
@@ -69,6 +70,7 @@
   readonly isWslSettingsRowVisible: boolean;
   readonly hasThreadAutoSettlement: boolean;
   readonly managedTunnelActive?: boolean;
+  readonly hasPeerLinkHost?: boolean;
 }
 
 export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
@@ -975,6 +977,14 @@
     ],
   },
   {
+    id: "linked-environments",
+    peerLinksOnly: true,
+    title: "Linked environments",
+    to: "/settings/connections",
+    targetId: "linked-environments",
+    searchTerms: ["link peer another machine vps agents work there handoff pairing code"],
+  },
+  {
     id: "remote-environments",
     title: "Environments",
     to: "/settings/connections",
@@ -1132,7 +1142,8 @@
       (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&
       (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
       (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement) &&
+      (!item.managedTunnelOnly || availability.managedTunnelActive === true) &&
+      (!item.peerLinksOnly || availability.hasPeerLinkHost === true),
-      (!item.managedTunnelOnly || availability.managedTunnelActive === true),
   );
 }
 
```

## apps/web/src/components/settings/useAvailableSettingsSearchItems.ts

Status: modified; +3; −0

```diff
@@ -6,6 +6,7 @@
 import { isLocalEnvironmentDisabled } from "~/localEnvironment";
 import { desktopWslStateAtom } from "~/state/desktopWslState";
 import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
+import { usePeerLinkHosts } from "~/state/peerLinks";
 import { useEnvironmentQuery } from "~/state/query";
 import { useEnvironmentScope } from "~/state/session";
 import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
@@ -45,6 +46,7 @@
     getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0;
   const primaryEnvironmentId = usePrimaryEnvironmentId();
   const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope);
+  const hasPeerLinkHost = usePeerLinkHosts().length > 0;
   const cloudLinkState = usePrimaryCloudLinkState().data;
   // Same fallback as the Connections row: older servers imply a tunnel from `linked`.
   const managedTunnelActive =
@@ -69,5 +71,6 @@
     isWslSettingsRowVisible: wslSettingsRowVisible,
     hasThreadAutoSettlement,
     managedTunnelActive,
+    hasPeerLinkHost,
   });
 }
```

## apps/web/src/state/peerLinks.ts

Status: added; +19; −0

```diff
@@ -1,0 +1,19 @@
+import { createPeerLinkEnvironmentAtoms } from "@t3tools/client-runtime/state/peerLinks";
+import { AuthAccessReadScope } from "@t3tools/contracts";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+import { useEnvironments } from "./environments";
+import { useEnvironmentsWithScope } from "./session";
+
+export const peerLinkEnvironment = createPeerLinkEnvironmentAtoms(connectionAtomRuntime);
+
+export function usePeerLinkHosts() {
+  const { environments } = useEnvironments();
+  const supported = environments.filter(
+    (environment) =>
+      environment.connection.phase === "connected" &&
+      environment.serverConfig?.environment.capabilities.peerLinks === true,
+  );
+  const readable = useEnvironmentsWithScope(supported, AuthAccessReadScope);
+  return supported.filter((environment) => readable.has(environment.environmentId));
+}
```

## docs/user/remote-access.md

Status: modified; +22; −0

```diff
@@ -246,6 +246,28 @@
 threads on an environment through its MCP server. See
 [outside agents](./outside-agents.md) for setup.
 
+## Link another environment
+
+Link two machines, such as your laptop and a VPS, and agents on one can work on
+the other: launch, message, wait on and stop its threads, answer their
+questions, and manage its queues, projects and scheduled tasks. On the machine
+you will work from, open **Settings → Connections → Linked environments**,
+choose **Link**, and enter the other machine's address and a pairing code from
+it. From a terminal, use
+`t3 environment link <address> --pairing-code <code> --access <mode>`.
+
+- The access you pick is the most agents from this machine may do there. An
+  agent also never gets more there than its own mode here.
+- Threads a link starts are marked **From** and this machine's name there.
+- A full-access link works there like that machine's own agents. Below full
+  access, the link's agents can change only threads it started, not that
+  machine's own threads, projects or settings, and they skip its project setup
+  scripts.
+- The link shows up in that machine's **Settings → Connections** like any
+  outside agent. Revoke it there to end it. **Forget** on this machine only
+  removes it here.
+- Links last 30 days. Link again with a new pairing code to renew one.
+
 ## Manage or revoke access
 
 On the host, **Settings → Connections** lets authorized administrators create
```

## packages/client-runtime/package.json

Status: modified; +4; −0

```diff
@@ -199,6 +199,10 @@
       "types": "./src/state/deviceHubAccess.ts",
       "default": "./src/state/deviceHubAccess.ts"
     },
+    "./state/peerLinks": {
+      "types": "./src/state/peerLinks.ts",
+      "default": "./src/state/peerLinks.ts"
+    },
     "./state/preview": {
       "types": "./src/state/preview.ts",
       "default": "./src/state/preview.ts"
```

## packages/client-runtime/src/state/commandPermissions.test.ts

Status: modified; +132; −0

```diff
@@ -7,6 +7,8 @@
 import { describe, expect, it } from "@effect/vitest";
 import { vi } from "vite-plus/test";
 import {
+  AuthAccessReadScope,
+  AuthAccessWriteScope,
   AuthOrchestrationOperateScope,
   AuthOrchestrationReadScope,
   AuthPreviewOperateScope,
@@ -17,8 +19,10 @@
   ScheduledTaskId,
   WS_METHODS,
   type AuthSessionState,
+  type PeerLinkSummary,
 } from "@t3tools/contracts";
 import * as Cause from "effect/Cause";
+import * as DateTime from "effect/DateTime";
 import * as Effect from "effect/Effect";
 import * as Fiber from "effect/Fiber";
 import * as Layer from "effect/Layer";
@@ -26,6 +30,8 @@
 import { EnvironmentRegistry } from "../connection/registry.ts";
 import { createCommandPermissions } from "./commandPermissions.ts";
 import { createEnvironmentRpcCommand } from "./runtime.ts";
+import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts";
+import { createPeerLinkEnvironmentAtoms } from "./peerLinks.ts";
 
 vi.mock("./session.ts", () => ({
   createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }),
@@ -291,6 +297,132 @@
     ),
 );
 
+it.effect(
+  "refreshes linked environments after permitted writes and blocks a read-only session",
+  () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const registry = yield* setup;
+        const peer: PeerLinkSummary = {
+          environmentId: EnvironmentId.make("peer"),
+          label: "Box",
+          urls: ["https://box.example.test"],
+          access: "auto",
+          linkedAt: DateTime.makeUnsafe("2026-10-01T12:00:00Z"),
+          expiresAt: DateTime.makeUnsafe("2026-10-31T12:00:00Z"),
+          lastReachedAt: null,
+          lastError: null,
+          status: "reachable",
+        };
+        let links: ReadonlyArray<PeerLinkSummary> = [];
+        const client = {
+          [WS_METHODS.peerLinksList]: () => Effect.sync(() => ({ links })),
+          [WS_METHODS.peerLinksLink]: vi.fn(() =>
+            Effect.sync(() => {
+              links = [peer];
+              return peer;
+            }),
+          ),
+          [WS_METHODS.peerLinksUnlink]: vi.fn(() =>
+            Effect.sync(() => {
+              links = [];
+              return { removed: true };
+            }),
+          ),
+        };
+        const supervisor = {
+          target: { environmentId: env },
+          session: yield* SubscriptionRef.make(Option.some({ client } as unknown as RpcSession)),
+          state: yield* SubscriptionRef.make({
+            ...AVAILABLE_CONNECTION_STATE,
+            phase: "connected" as const,
+          }),
+        } as EnvironmentSupervisor["Service"];
+        const peerRuntime = Atom.runtime(
+          Layer.succeed(EnvironmentRegistry, {
+            run: (id, effect) => {
+              expect(id).toBe(env);
+              return Effect.provideService(effect, EnvironmentSupervisor, supervisor);
+            },
+            followStream: (id, stream) => {
+              expect(id).toBe(env);
+              return Stream.provideService(stream, EnvironmentSupervisor, supervisor);
+            },
+          } as EnvironmentRegistry["Service"]),
+        );
+        const peerLinks = createPeerLinkEnvironmentAtoms(peerRuntime);
+        const list = peerLinks.list({ environmentId: env, input: {} });
+        registry.mount(list);
+        const readLinks = AtomRegistry.getResult(registry, list, { suspendOnWaiting: true });
+        const setAccess = (writable: boolean) =>
+          registry.set(
+            sessions(env),
+            AsyncResult.success({
+              ...grant(false),
+              scopes: [AuthAccessReadScope, ...(writable ? [AuthAccessWriteScope] : [])],
+              permissions: [AuthAccessReadScope, ...(writable ? [AuthAccessWriteScope] : [])],
+            }),
+          );
+        registry.mount(sessions(other));
+        registry.set(
+          sessions(other),
+          AsyncResult.success({
+            ...grant(false),
+            scopes: [AuthAccessWriteScope],
+            permissions: [AuthAccessWriteScope],
+          }),
+        );
+        setAccess(false);
+        expect(yield* readLinks).toEqual({ links: [] });
+        const linkInput = { url: peer.urls[0]!, pairingCode: "test-code", access: peer.access };
+        const link = Effect.promise(() =>
+          peerLinks.link.run(registry, { environmentId: env, input: linkInput }),
+        );
+        const unlink = Effect.promise(() =>
+          peerLinks.unlink.run(registry, {
+            environmentId: env,
+            input: { environmentId: peer.environmentId },
+          }),
+        );
+        expect(registry.get(peerLinks.link.permissionAtom(env))).toBe(false);
+        const deniedLink = yield* link;
+        expect(deniedLink._tag).toBe("Failure");
+        if (deniedLink._tag === "Failure") {
+          expect(Cause.squash(deniedLink.cause)).toMatchObject({
+            _tag: "EnvironmentAuthorizationError",
+            requiredPermission: AuthAccessWriteScope,
+          });
+        }
+        expect(client[WS_METHODS.peerLinksLink]).not.toHaveBeenCalled();
+        setAccess(true);
+        expect(registry.get(peerLinks.link.permissionAtom(env))).toBe(true);
+        expect((yield* link)._tag).toBe("Success");
+        expect(client[WS_METHODS.peerLinksLink]).toHaveBeenCalledExactlyOnceWith(linkInput);
+        expect(yield* readLinks).toEqual({ links: [peer] });
+
+        setAccess(false);
+        expect(registry.get(peerLinks.unlink.permissionAtom(env))).toBe(false);
+        const deniedUnlink = yield* unlink;
+        expect(deniedUnlink._tag).toBe("Failure");
+        if (deniedUnlink._tag === "Failure") {
+          expect(Cause.squash(deniedUnlink.cause)).toMatchObject({
+            _tag: "EnvironmentAuthorizationError",
+            requiredPermission: AuthAccessWriteScope,
+          });
+        }
+        expect(client[WS_METHODS.peerLinksUnlink]).not.toHaveBeenCalled();
+        expect(yield* readLinks).toEqual({ links: [peer] });
+        setAccess(true);
+        expect(registry.get(peerLinks.unlink.permissionAtom(env))).toBe(true);
+        expect((yield* unlink)._tag).toBe("Success");
+        expect(client[WS_METHODS.peerLinksUnlink]).toHaveBeenCalledExactlyOnceWith({
+          environmentId: peer.environmentId,
+        });
+        expect(yield* readLinks).toEqual({ links: [] });
+      }),
+    ),
+);
+
 it.effect("honors exact empty permissions and preserves legacy parent grants", () =>
   Effect.scoped(
     Effect.gen(function* () {
```

## packages/client-runtime/src/state/models.ts

Status: modified; +2; −0

```diff
@@ -100,6 +100,7 @@
   readonly worktreePath: string | null;
   readonly lineage: OrchestrationV2ThreadShell["lineage"];
   readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"];
+  readonly linkOrigin: OrchestrationV2ThreadShell["linkOrigin"] | null;
   readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"];
   readonly latestRun: ThreadRunSummary | null;
   readonly runtime: ThreadRuntimeSummary | null;
@@ -246,6 +247,7 @@
     branchPullRequest: thread.branchPullRequest ?? null,
     lineage: thread.lineage,
     forkedFrom: thread.forkedFrom,
+    linkOrigin: thread.linkOrigin ?? null,
     activeProviderThreadId: thread.activeProviderThreadId,
     latestRun,
     runtime,
```

## packages/client-runtime/src/state/peerLinks.ts

Status: added; +57; −0

```diff
@@ -1,0 +1,57 @@
+import { type PeerLinkSummary, WS_METHODS } from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import { Atom } from "effect/reactivity";
+
+import type { EnvironmentRegistry } from "../connection/registry.ts";
+import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
+
+export function createPeerLinkEnvironmentAtoms<R, E>(
+  runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
+) {
+  const list = createEnvironmentRpcQueryAtomFamily(runtime, {
+    label: "environment-data:peer-links:list",
+    tag: WS_METHODS.peerLinksList,
+    // Listing probes every linked environment, so a list stays fresh briefly.
+    staleTimeMs: 10_000,
+  });
+  return {
+    list,
+    link: createEnvironmentRpcCommand(runtime, {
+      label: "environment-data:peer-links:link",
+      tag: WS_METHODS.peerLinksLink,
+      onSuccess: ({ environmentId }, registry) =>
+        Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))),
+    }),
+    unlink: createEnvironmentRpcCommand(runtime, {
+      label: "environment-data:peer-links:unlink",
+      tag: WS_METHODS.peerLinksUnlink,
+      onSuccess: ({ environmentId }, registry) =>
+        Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))),
+    }),
+  };
+}
+
+const EXPIRY_WARNING_DAYS = 5;
+
+export type PeerLinkHealth =
+  | { readonly kind: "reachable"; readonly expiresInDays: number | null }
+  | { readonly kind: "unreachable"; readonly detail: string | null }
+  | { readonly kind: "failing"; readonly detail: string }
+  | { readonly kind: "expired" };
+
+/**
+ * Listing only checks that the peer answers, so a link whose last call failed
+ * (the peer revoked it, say) is `failing` with the recorded reason; the next
+ * successful call clears it.
+ */
+export function peerLinkHealth(link: PeerLinkSummary, now: DateTime.Utc): PeerLinkHealth {
+  if (link.status === "expired" || DateTime.isLessThanOrEqualTo(link.expiresAt, now)) {
+    return { kind: "expired" };
+  }
+  if (link.status === "unreachable") return { kind: "unreachable", detail: link.lastError };
+  if (link.lastError !== null) return { kind: "failing", detail: link.lastError };
+  const days = Math.ceil(Duration.toDays(DateTime.distance(now, link.expiresAt)));
+  return { kind: "reachable", expiresInDays: days <= EXPIRY_WARNING_DAYS ? days : null };
+}
```

## packages/contracts/src/clientRpcPermissions.ts

Status: modified; +4; −0

```diff
@@ -1,6 +1,7 @@
 import * as Schema from "effect/Schema";
 import { GitPreparePullRequestThreadInput, GitRunStackedActionInput } from "./git.ts";
 import {
+  AuthAccessWriteScope,
   AuthOrchestrationOperateScope,
   AuthOrchestrationReadScope,
   AuthPreviewOperateScope,
@@ -49,6 +50,9 @@
   [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
   [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
   [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
+
+  [WS_METHODS.peerLinksLink]: AuthAccessWriteScope,
+  [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope,
 } as const;
 export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;
 
```
