From 444fd8badd265ddde33babf2a21bf3f527334e98 Mon Sep 17 00:00:00 2001
From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com>
Date: Thu, 10 Sep 2026 13:33:09 -0400
Subject: [PATCH 01/15] fix(mobile): keep Android markdown icons aligned with
text (#11079)
---
.../t3-markdown-text/android/build.gradle | 12 +++++
.../T3MarkdownTextSelectionModule.kt | 14 ++++--
.../MarkdownSelectionCopyTest.kt | 48 +++++++++++++++++++
.../src/NativeMarkdownBlock.ios.tsx | 35 ++++++++++----
.../src/NativeMarkdownSelectableText.ios.tsx | 4 ++
.../src/SelectableMarkdownText.ios.tsx | 6 ++-
.../src/nativeMarkdownText.ts | 14 ++++--
.../mobile/src/lib/nativeMarkdownText.test.ts | 6 +++
docs/user/composer.md | 3 ++
9 files changed, 120 insertions(+), 22 deletions(-)
create mode 100644 apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt
diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle
index 13584a00be42..5b7e372006f3 100644
--- a/apps/mobile/modules/t3-markdown-text/android/build.gradle
+++ b/apps/mobile/modules/t3-markdown-text/android/build.gradle
@@ -8,6 +8,10 @@ android {
namespace 'expo.modules.t3markdowntext'
compileSdk rootProject.ext.compileSdkVersion
+ testOptions {
+ unitTests.includeAndroidResources = true
+ }
+
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
@@ -17,4 +21,12 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation 'com.facebook.react:react-android'
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.robolectric:robolectric:4.16.1'
+}
+
+tasks.withType(Test).configureEach {
+ javaLauncher = javaToolchains.launcherFor {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
}
diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
index af8675831f2d..c68b8e641aab 100644
--- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
+++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
@@ -18,19 +18,23 @@ import kotlin.math.min
private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
-private fun copyTextWithoutInlineImages(
+internal fun copyTextWithoutInlineImages(
text: CharSequence,
start: Int,
end: Int
): String {
if (text !is Spanned) return text.subSequence(start, end).toString()
+ fun isInlineImage(index: Int): Boolean =
+ index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
+ text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
+
return buildString {
for (index in start until end) {
- val isInlineImage =
- text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
- text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
- if (!isInlineImage) append(text[index])
+ // The renderer inserts one NBSP after each image to keep its label on the same line.
+ // Inspect the original text even when selection starts after the image.
+ val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1)
+ if (!isInlineImage(index) && !isIconSpacer) append(text[index])
}
}
}
diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt
new file mode 100644
index 000000000000..da9012ee1665
--- /dev/null
+++ b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt
@@ -0,0 +1,48 @@
+package expo.modules.t3markdowntext
+
+import android.graphics.drawable.ColorDrawable
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.ImageSpan
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36], manifest = Config.NONE)
+class MarkdownSelectionCopyTest {
+ private fun withIcon(value: String): SpannableString = SpannableString(value).apply {
+ val index = value.indexOf('\uFFFC')
+ setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+
+ @Test
+ fun removesIconAndInjectedSpacer() {
+ val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
+ assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length))
+ }
+
+ @Test
+ fun removesSpacerWhenSelectionStartsAfterIcon() {
+ val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
+ assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12))
+ }
+
+ @Test
+ fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() {
+ val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0")
+ assertEquals(
+ "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0",
+ copyTextWithoutInlineImages(text, 0, text.length)
+ )
+ }
+
+ @Test
+ fun preservesTextWithoutImageSpans() {
+ val text = "\uFFFC\u00A0main.go"
+ assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length))
+ assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length))
+ }
+}
diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
index 348a3c489a2c..3aa7e1cf4e46 100644
--- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
@@ -4,7 +4,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { CopyTextButton } from "./CopyTextButton";
import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
-import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
+import {
+ nativeMarkdownBlockSpacing,
+ nativeMarkdownDocumentRuns,
+ nativeMarkdownListItemBlocks,
+} from "./nativeMarkdownText";
import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
import type {
MarkdownCodeHighlighter,
@@ -595,17 +599,28 @@ export function NativeMarkdownBlock(props: {
switch (props.node.type) {
case "document":
return (
-
+
{(props.node.children ?? []).map((child, index) => (
-
+ style={{
+ paddingTop:
+ Platform.OS === "android"
+ ? nativeMarkdownBlockSpacing(props.node.children?.[index - 1], child)
+ : index > 0
+ ? 8
+ : 0,
+ }}
+ >
+
+
))}
);
diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx
index a5c6cf540f1c..7e1eaf88f546 100644
--- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx
@@ -222,6 +222,10 @@ export function NativeMarkdownSelectableText(props: {
}
}
+ if (Platform.OS === "android" && (run.fileIcon || linkIcon)) {
+ text = `\u00A0${text}`;
+ }
+
return { key: `${signature}:${occurrence}`, run, text, linkIcon };
});
// T3MarkdownText only rebuilds its attributed string during native layout. A
diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
index 2a231c603584..b7768ffbae03 100644
--- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
@@ -1,5 +1,5 @@
import { useMemo } from "react";
-import { View } from "react-native";
+import { Platform, View } from "react-native";
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
import {
@@ -84,8 +84,10 @@ export function SelectableMarkdownText({
the resulting single-line width instead of reflowing it. */}
{chunks.map((chunk, index) => {
+ // Android inline images drift when one Text mixes paragraph and list
+ // spacer line heights. Keep those layouts in separate native blocks.
const content =
- chunk.kind === "rich" ? (
+ chunk.kind === "rich" || Platform.OS === "android" ? (
0) {
- const previous = children[index - 1];
- appendSpacer(
- runs,
- child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
- );
+ appendSpacer(runs, nativeMarkdownBlockSpacing(children[index - 1], child));
}
appendDocumentBlock(runs, child, depth);
}
diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts
index c3951c8d81f0..1091aeb65a6c 100644
--- a/apps/mobile/src/lib/nativeMarkdownText.test.ts
+++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import {
+ nativeMarkdownBlockSpacing,
nativeMarkdownChunkSpacing,
nativeMarkdownDocumentChunks,
nativeMarkdownDocumentRuns,
@@ -381,6 +382,11 @@ describe("nativeMarkdownDocumentRuns", () => {
.filter((run) => run.role === "spacer")
.map((run) => run.spacing),
).toEqual([20, 10, 12]);
+ expect(
+ node.children?.map((child, index) =>
+ nativeMarkdownBlockSpacing(node.children?.[index - 1], child),
+ ),
+ ).toEqual([0, 20, 10, 12]);
});
it("renders tight list items whose inline nodes are direct children", () => {
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 4a8df5333664..7e388d4eb34f 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -6,6 +6,9 @@ include a skill when the task needs more context.
Messages can contain up to 120,000 characters. Longer drafts stay in the composer
so you can shorten them or split them into several messages.
+On Android, long-press message text to select within a paragraph or list item.
+Use the message’s copy button to copy the whole message.
+
## Attach files
Attach up to eight files per message. Images can be up to 10 MB; other files can
From 3836890e4484406813997259efc69065b25ce698 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 10 Sep 2026 10:35:34 -0700
Subject: [PATCH 02/15] Revert "fix(mobile): keep Android markdown icons
aligned with text" (#11098)
---
.../t3-markdown-text/android/build.gradle | 12 -----
.../T3MarkdownTextSelectionModule.kt | 14 ++----
.../MarkdownSelectionCopyTest.kt | 48 -------------------
.../src/NativeMarkdownBlock.ios.tsx | 35 ++++----------
.../src/NativeMarkdownSelectableText.ios.tsx | 4 --
.../src/SelectableMarkdownText.ios.tsx | 6 +--
.../src/nativeMarkdownText.ts | 14 ++----
.../mobile/src/lib/nativeMarkdownText.test.ts | 6 ---
docs/user/composer.md | 3 --
9 files changed, 22 insertions(+), 120 deletions(-)
delete mode 100644 apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt
diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle
index 5b7e372006f3..13584a00be42 100644
--- a/apps/mobile/modules/t3-markdown-text/android/build.gradle
+++ b/apps/mobile/modules/t3-markdown-text/android/build.gradle
@@ -8,10 +8,6 @@ android {
namespace 'expo.modules.t3markdowntext'
compileSdk rootProject.ext.compileSdkVersion
- testOptions {
- unitTests.includeAndroidResources = true
- }
-
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
@@ -21,12 +17,4 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation 'com.facebook.react:react-android'
- testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.robolectric:robolectric:4.16.1'
-}
-
-tasks.withType(Test).configureEach {
- javaLauncher = javaToolchains.launcherFor {
- languageVersion = JavaLanguageVersion.of(21)
- }
}
diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
index c68b8e641aab..af8675831f2d 100644
--- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
+++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
@@ -18,23 +18,19 @@ import kotlin.math.min
private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
-internal fun copyTextWithoutInlineImages(
+private fun copyTextWithoutInlineImages(
text: CharSequence,
start: Int,
end: Int
): String {
if (text !is Spanned) return text.subSequence(start, end).toString()
- fun isInlineImage(index: Int): Boolean =
- index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
- text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
-
return buildString {
for (index in start until end) {
- // The renderer inserts one NBSP after each image to keep its label on the same line.
- // Inspect the original text even when selection starts after the image.
- val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1)
- if (!isInlineImage(index) && !isIconSpacer) append(text[index])
+ val isInlineImage =
+ text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
+ text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
+ if (!isInlineImage) append(text[index])
}
}
}
diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt
deleted file mode 100644
index da9012ee1665..000000000000
--- a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package expo.modules.t3markdowntext
-
-import android.graphics.drawable.ColorDrawable
-import android.text.SpannableString
-import android.text.Spanned
-import android.text.style.ImageSpan
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-
-@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [36], manifest = Config.NONE)
-class MarkdownSelectionCopyTest {
- private fun withIcon(value: String): SpannableString = SpannableString(value).apply {
- val index = value.indexOf('\uFFFC')
- setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
- }
-
- @Test
- fun removesIconAndInjectedSpacer() {
- val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
- assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length))
- }
-
- @Test
- fun removesSpacerWhenSelectionStartsAfterIcon() {
- val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
- assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12))
- }
-
- @Test
- fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() {
- val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0")
- assertEquals(
- "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0",
- copyTextWithoutInlineImages(text, 0, text.length)
- )
- }
-
- @Test
- fun preservesTextWithoutImageSpans() {
- val text = "\uFFFC\u00A0main.go"
- assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length))
- assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length))
- }
-}
diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
index 3aa7e1cf4e46..348a3c489a2c 100644
--- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
@@ -4,11 +4,7 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { CopyTextButton } from "./CopyTextButton";
import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
-import {
- nativeMarkdownBlockSpacing,
- nativeMarkdownDocumentRuns,
- nativeMarkdownListItemBlocks,
-} from "./nativeMarkdownText";
+import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
import type {
MarkdownCodeHighlighter,
@@ -599,28 +595,17 @@ export function NativeMarkdownBlock(props: {
switch (props.node.type) {
case "document":
return (
-
+
{(props.node.children ?? []).map((child, index) => (
- 0
- ? 8
- : 0,
- }}
- >
-
-
+ node={child}
+ skills={props.skills}
+ textStyle={props.textStyle}
+ highlightCode={props.highlightCode}
+ onLinkPress={props.onLinkPress}
+ depth={depth}
+ />
))}
);
diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx
index 7e1eaf88f546..a5c6cf540f1c 100644
--- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx
@@ -222,10 +222,6 @@ export function NativeMarkdownSelectableText(props: {
}
}
- if (Platform.OS === "android" && (run.fileIcon || linkIcon)) {
- text = `\u00A0${text}`;
- }
-
return { key: `${signature}:${occurrence}`, run, text, linkIcon };
});
// T3MarkdownText only rebuilds its attributed string during native layout. A
diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
index b7768ffbae03..2a231c603584 100644
--- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
@@ -1,5 +1,5 @@
import { useMemo } from "react";
-import { Platform, View } from "react-native";
+import { View } from "react-native";
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
import {
@@ -84,10 +84,8 @@ export function SelectableMarkdownText({
the resulting single-line width instead of reflowing it. */}
{chunks.map((chunk, index) => {
- // Android inline images drift when one Text mixes paragraph and list
- // spacer line heights. Keep those layouts in separate native blocks.
const content =
- chunk.kind === "rich" || Platform.OS === "android" ? (
+ chunk.kind === "rich" ? (
0) {
- appendSpacer(runs, nativeMarkdownBlockSpacing(children[index - 1], child));
+ const previous = children[index - 1];
+ appendSpacer(
+ runs,
+ child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
+ );
}
appendDocumentBlock(runs, child, depth);
}
diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts
index 1091aeb65a6c..c3951c8d81f0 100644
--- a/apps/mobile/src/lib/nativeMarkdownText.test.ts
+++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import {
- nativeMarkdownBlockSpacing,
nativeMarkdownChunkSpacing,
nativeMarkdownDocumentChunks,
nativeMarkdownDocumentRuns,
@@ -382,11 +381,6 @@ describe("nativeMarkdownDocumentRuns", () => {
.filter((run) => run.role === "spacer")
.map((run) => run.spacing),
).toEqual([20, 10, 12]);
- expect(
- node.children?.map((child, index) =>
- nativeMarkdownBlockSpacing(node.children?.[index - 1], child),
- ),
- ).toEqual([0, 20, 10, 12]);
});
it("renders tight list items whose inline nodes are direct children", () => {
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 7e388d4eb34f..4a8df5333664 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -6,9 +6,6 @@ include a skill when the task needs more context.
Messages can contain up to 120,000 characters. Longer drafts stay in the composer
so you can shorten them or split them into several messages.
-On Android, long-press message text to select within a paragraph or list item.
-Use the message’s copy button to copy the whole message.
-
## Attach files
Attach up to eight files per message. Images can be up to 10 MB; other files can
From e784975bbec7c54ed75fc6da3d4ffd4038fae041 Mon Sep 17 00:00:00 2001
From: maria
Date: Thu, 10 Sep 2026 15:24:25 -0300
Subject: [PATCH 03/15] fix(ui): simplify multiple linked pull request badges
(#11104)
---
.../src/features/threads/thread-list-v2-items.tsx | 14 ++++++++++----
apps/mobile/src/state/thread-pr-presentation.ts | 9 +++++++--
apps/mobile/src/state/use-thread-pr.test.ts | 2 +-
apps/web/src/components/Sidebar.tsx | 2 +-
apps/web/src/components/ThreadStatusIndicators.tsx | 9 +++++----
5 files changed, 24 insertions(+), 12 deletions(-)
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 834da0af9a6a..67a917c0079c 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -871,16 +871,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
{pr ? (
- {pr.kind === "stack" ? (
+ {pr.kind === "stack" || pr.others > 0 ? (
) : null}
@@ -896,7 +902,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
style={{ fontFamily: MONO_FONT }}
>
- {pr.kind === "stack" ? pr.label : `#${pr.label}`}
+ {pr.kind === "stack" || pr.others > 0 ? pr.label : `#${pr.label}`}
) : null}
diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts
index dd21e2d70ec4..48fe3abf5f66 100644
--- a/apps/mobile/src/state/thread-pr-presentation.ts
+++ b/apps/mobile/src/state/thread-pr-presentation.ts
@@ -17,11 +17,12 @@ export interface ThreadPrPresentation {
readonly number: number;
readonly state: ThreadPr["state"] | null;
readonly kind: "pull-request" | "stack";
+ readonly others: number;
readonly isDraft: boolean;
/** Provider-side last activity, bounding when a terminal state landed. */
readonly updatedAt: string | null;
readonly url: string;
- /** Compact pull request number label, e.g. "3774". */
+ /** Compact pull request number or linked count, e.g. "3774" or "+2". */
readonly label: string;
/** Full, provider-aware label for assistive technologies. */
readonly accessibilityLabel: string;
@@ -42,6 +43,7 @@ export function presentThreadPr(
const isDraft = pr.state === "open" && pr.isDraft === true;
return {
kind: "pull-request",
+ others: 0,
number: pr.number,
state: pr.state,
isDraft,
@@ -66,9 +68,12 @@ export function presentThreadLinkedPullRequests(
const label =
badge.kind === "stack"
? String(badge.layers)
- : `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`;
+ : badge.others > 0
+ ? `+${badge.others}`
+ : String(link.number);
return {
kind: badge.kind,
+ others: badge.kind === "pull-request" ? badge.others : 0,
number: link.number,
state,
isDraft,
diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts
index 330e31cb0171..5fdb90e19c65 100644
--- a/apps/mobile/src/state/use-thread-pr.test.ts
+++ b/apps/mobile/src/state/use-thread-pr.test.ts
@@ -87,7 +87,7 @@ describe("presentThreadLinkedPullRequests", () => {
it("counts unrelated links without labelling them a stack", () => {
expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({
kind: "pull-request",
- label: "1 +1",
+ label: "+1",
});
});
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 79573635212e..72a119cb76ba 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1485,7 +1485,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
);
- // Stacks show their layer count; unrelated links show the current PR and a remainder count.
+ // Stacks show their layer count; unrelated links show only the remainder count.
// Plain clicks open T3; individual PR links also support opening the host in a new tab.
const prBadgeShape = supportsMultiplePullRequests
? resolveThreadPullRequestBadge(thread.pullRequests)
diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx
index 6a73a805fb67..2d724a726506 100644
--- a/apps/web/src/components/ThreadStatusIndicators.tsx
+++ b/apps/web/src/components/ThreadStatusIndicators.tsx
@@ -174,10 +174,11 @@ export function ThreadPullRequestBadgeControl({
const content = (
<>
- {isStack ? badge.layers : number}
- {badge?.kind === "pull-request" && badge.others > 0 ? (
- +{badge.others}
- ) : null}
+ {isStack
+ ? badge.layers
+ : badge?.kind === "pull-request" && badge.others > 0
+ ? `+${badge.others}`
+ : number}
>
);
return (
From 0882431e0fc0fabe950743dd44b674a07a584bc9 Mon Sep 17 00:00:00 2001
From: maria
Date: Thu, 10 Sep 2026 15:25:31 -0300
Subject: [PATCH 04/15] fix(preview): return to pip when closing the right
panel (#11102)
---
apps/web/src/components/ChatView.tsx | 27 +++++++++++++++++++--------
1 file changed, 19 insertions(+), 8 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index f1b5afba94e9..597b7333ce28 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -4293,10 +4293,21 @@ export default function ChatView(props: ChatViewProps) {
supportsPullRequests,
threadDetailLoading,
]);
+ const closePreviewPanel = useCallback(() => {
+ if (activeThreadRef) {
+ if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
+ usePreviewMiniPlayerStore
+ .getState()
+ .open(activeThreadRef, activeRightPanelSurface.resourceId);
+ }
+ setMaximizedRightPanelThreadKey(null);
+ useRightPanelStore.getState().close(activeThreadRef);
+ }
+ }, [activeRightPanelSurface, activeThreadRef]);
const togglePreviewPanel = useCallback(() => {
if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
if (previewPanelOpen) {
- useRightPanelStore.getState().close(activeThreadRef);
+ closePreviewPanel();
return;
}
const activeTabId = activePreviewState.activeTabId;
@@ -4305,13 +4316,13 @@ export default function ChatView(props: ChatViewProps) {
} else {
createBrowserSurface();
}
- }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]);
- const closePreviewPanel = useCallback(() => {
- if (activeThreadRef) {
- setMaximizedRightPanelThreadKey(null);
- useRightPanelStore.getState().close(activeThreadRef);
- }
- }, [activeThreadRef]);
+ }, [
+ activePreviewState.activeTabId,
+ activeThreadRef,
+ closePreviewPanel,
+ createBrowserSurface,
+ previewPanelOpen,
+ ]);
const addTerminalSurface = useCallback(() => {
if (!activeThreadRef || !activeThreadId || !activeProject) return;
const cwd = gitCwd ?? activeProject.workspaceRoot;
From 0527ddf06defc88169c245a0fe53635d41d66469 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 10 Sep 2026 11:34:55 -0700
Subject: [PATCH 05/15] fix: quiet settled threads and simplify PR badges
(#11101)
---
.../src/features/threads/thread-list-items.tsx | 4 ++--
.../src/features/threads/thread-list-v2-items.tsx | 8 +-------
apps/mobile/src/state/thread-pr-presentation.ts | 10 +++++++---
apps/mobile/src/state/use-thread-pr.test.ts | 4 +++-
apps/web/src/components/Sidebar.tsx | 10 ++++++----
apps/web/src/components/ThreadStatusIndicators.tsx | 13 +++++++------
6 files changed, 26 insertions(+), 23 deletions(-)
diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx
index 0ec50c674451..a4da32ef7018 100644
--- a/apps/mobile/src/features/threads/thread-list-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-items.tsx
@@ -44,11 +44,11 @@ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
const SIDEBAR_ROW_RADIUS = 12;
function pullRequestTintColor(
- pr: Pick,
+ pr: Pick,
colorScheme: "light" | "dark",
) {
const dark = colorScheme === "dark";
- if (pr.state === "open" && pr.isDraft === true) {
+ if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) {
return dark ? "#a1a1aa" : "#71717a";
}
switch (pr.state) {
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 67a917c0079c..937ca5f909a8 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -880,13 +880,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
? materialYouStyleLayoutActive
? "accent-thread-selected-foreground"
: "accent-user-bubble-foreground"
- : pr.kind === "stack" || pr.isDraft || pr.state === null
- ? "accent-foreground-muted"
- : pr.state === "open"
- ? "accent-adaptive-emerald-600-400"
- : pr.state === "merged"
- ? "accent-adaptive-violet-600-400"
- : "accent-foreground-muted"
+ : "accent-foreground-muted"
}
/>
) : null}
diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts
index 48fe3abf5f66..fc310d070acc 100644
--- a/apps/mobile/src/state/thread-pr-presentation.ts
+++ b/apps/mobile/src/state/thread-pr-presentation.ts
@@ -65,11 +65,12 @@ export function presentThreadLinkedPullRequests(
const snapshot = link.snapshot;
const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null);
const isDraft = snapshot?.isDraft === true && state === "open";
+ const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;
const label =
badge.kind === "stack"
? String(badge.layers)
- : badge.others > 0
- ? `+${badge.others}`
+ : linkedCount !== null
+ ? `+${linkedCount}`
: String(link.number);
return {
kind: badge.kind,
@@ -84,7 +85,10 @@ export function presentThreadLinkedPullRequests(
badge.kind === "stack"
? `${badge.layers} pull requests in stack, ${state ?? "status pending"}`
: `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`,
- textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state],
+ textClassName:
+ linkedCount !== null || state === null || isDraft
+ ? "text-foreground-muted"
+ : PR_STATE_TEXT_CLASS[state],
};
}
diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts
index 5fdb90e19c65..e36bd7d81434 100644
--- a/apps/mobile/src/state/use-thread-pr.test.ts
+++ b/apps/mobile/src/state/use-thread-pr.test.ts
@@ -87,7 +87,9 @@ describe("presentThreadLinkedPullRequests", () => {
it("counts unrelated links without labelling them a stack", () => {
expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({
kind: "pull-request",
- label: "+1",
+ label: "+2",
+ others: 1,
+ textClassName: "text-foreground-muted",
});
});
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 72a119cb76ba..b1583a346b56 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1387,6 +1387,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// content; surface is reserved for interaction (hover, multi-select, route).
const rowSurfaceClassName = cn(
"group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none",
+ variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70",
props.isActive
? "bg-sidebar-row-active text-sidebar-foreground"
: isSelected
@@ -1469,7 +1470,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
: "text-foreground/90",
)
: cn(
- "truncate group-hover/sidebar-row:text-foreground",
+ "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground",
shouldRecede
? "text-secondary-label/70"
: props.isActive || isWoke
@@ -1485,7 +1486,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
);
- // Stacks show their layer count; unrelated links show only the remainder count.
+ // Stacks show their layer count; multiple unrelated links show their total count.
// Plain clicks open T3; individual PR links also support opening the host in a new tab.
const prBadgeShape = supportsMultiplePullRequests
? resolveThreadPullRequestBadge(thread.pullRequests)
@@ -1597,8 +1598,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{props.project ? : null}
@@ -1617,6 +1618,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
{prBadge &&
+ variantAction !== "unsettle" &&
pr &&
(supportsMultiplePullRequests
? visibleThreadPullRequests(thread.pullRequests).length === 0
diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx
index 2d724a726506..e948aa4091ee 100644
--- a/apps/web/src/components/ThreadStatusIndicators.tsx
+++ b/apps/web/src/components/ThreadStatusIndicators.tsx
@@ -154,6 +154,7 @@ export function ThreadPullRequestBadgeControl({
onOpenPullRequest: (event: MouseEvent) => void;
}) {
const isStack = badge?.kind === "stack";
+ const linkedCount = badge?.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;
if (!isStack && (number === undefined || url === undefined)) return null;
const label = isStack
? `Stack of ${badge.layers} pull requests, ${badge.state}`
@@ -169,16 +170,16 @@ export function ThreadPullRequestBadgeControl({
"text-xs tabular-nums",
variant === "ghost" &&
"font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",
- isStack ? PR_STATE_COLOR_CLASS[badge.state] : (status?.colorClass ?? "text-muted-foreground"),
+ linkedCount !== null
+ ? "text-secondary-label"
+ : isStack
+ ? PR_STATE_COLOR_CLASS[badge.state]
+ : (status?.colorClass ?? "text-muted-foreground"),
);
const content = (
<>
- {isStack
- ? badge.layers
- : badge?.kind === "pull-request" && badge.others > 0
- ? `+${badge.others}`
- : number}
+ {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number}
>
);
return (
From f814983c262b42bd79247bae377a709925c70d63 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 10 Sep 2026 11:34:56 -0700
Subject: [PATCH 06/15] fix(web): emphasize primary pull request actions
(#11105)
---
.../pullRequest/PullRequestDetailPanel.tsx | 18 ++++++------------
1 file changed, 6 insertions(+), 12 deletions(-)
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 54ad13980236..236d7cd86ad9 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -1728,7 +1728,7 @@ export function PullRequestDetailPanel({
void perform("ready")}
aria-label="Ready for review"
>
-
+
Ready for review
@@ -1777,7 +1774,7 @@ export function PullRequestDetailPanel({
setConfirmation({ open: true, action: "enable-auto-merge" })
@@ -1829,17 +1826,14 @@ export function PullRequestDetailPanel({
setConfirmation({ open: true, action: "merge" })}
aria-label={
pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel
}
>
-
+
{pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
From 21a5ccf881abc3aa8b5ea9de4a65edbb6ceadbda Mon Sep 17 00:00:00 2001
From: Henry Zhang <113233555+caezium@users.noreply.github.com>
Date: Fri, 11 Sep 2026 02:36:45 +0800
Subject: [PATCH 07/15] fix(web): prevent seams in the topbar scroll fade
(#10914)
---
apps/web/src/index.css | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index f7f669b804fd..c11b2d0f4ffe 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -363,7 +363,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
-webkit-mask-position: top, bottom, right;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size:
- 100% var(--workspace-titlebar-scroll-fade-height),
+ 100% calc(var(--workspace-titlebar-scroll-fade-height) + 1px),
100% calc(100% - var(--workspace-titlebar-scroll-fade-height)),
var(--app-scrollbar-width) 100%;
mask-image:
@@ -381,7 +381,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
mask-position: top, bottom, right;
mask-repeat: no-repeat;
mask-size:
- 100% var(--workspace-titlebar-scroll-fade-height),
+ 100% calc(var(--workspace-titlebar-scroll-fade-height) + 1px),
100% calc(100% - var(--workspace-titlebar-scroll-fade-height)),
var(--app-scrollbar-width) 100%;
}
From 21d744039bf76c072c875886ee497e391a0dd430 Mon Sep 17 00:00:00 2001
From: Matthew Feroz <136640686+MatthewFeroz@users.noreply.github.com>
Date: Thu, 10 Sep 2026 14:36:55 -0400
Subject: [PATCH 08/15] fix(web): fit provider update text inside sidebar
notices (#11034)
---
.../sidebar/SidebarProviderUpdatePill.tsx | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx
index 066b7e583253..7b1d5c25fcf5 100644
--- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx
@@ -126,7 +126,7 @@ export function SidebarProviderUpdatePill() {
return (
);
})}
diff --git a/apps/web/src/components/device/DeviceLoadingView.tsx b/apps/web/src/components/device/DeviceLoadingView.tsx
new file mode 100644
index 000000000000..70e848c3ce7e
--- /dev/null
+++ b/apps/web/src/components/device/DeviceLoadingView.tsx
@@ -0,0 +1,47 @@
+import { Smartphone } from "lucide-react";
+
+import { Spinner } from "~/components/ui/spinner";
+
+export function DeviceLoadingView(props: {
+ readonly name: string;
+ readonly description?: string;
+ readonly stage: "opening" | "stream";
+ readonly message: string;
+ readonly error?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
{props.name}
+ {props.description ? (
+
{props.description}
+ ) : null}
+
+
+ {!props.error ? : null}
+ {props.message}
+
+ {!props.error ? (
+
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx
index d94913b03dff..5b312f7fc4f4 100644
--- a/apps/web/src/components/device/DevicePanel.tsx
+++ b/apps/web/src/components/device/DevicePanel.tsx
@@ -6,7 +6,6 @@ import type {
} from "@t3tools/contracts";
import {
ChevronLeft,
- Circle,
Home,
Power,
RotateCcw,
@@ -15,21 +14,13 @@ import {
Square,
X,
} from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
import { Button } from "~/components/ui/button";
import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
import { Dialog } from "~/components/ui/dialog";
import { WizardPopup } from "~/components/ui/wizard";
-import {
- Select,
- SelectGroup,
- SelectGroupLabel,
- SelectItem,
- SelectPopup,
- SelectTrigger,
- SelectValue,
-} from "~/components/ui/select";
import { Spinner } from "~/components/ui/spinner";
import { Toggle } from "~/components/ui/toggle";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
@@ -38,28 +29,22 @@ import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/d
import { formatEnvironmentQueryError } from "~/state/query";
import { useAtomCommand } from "~/state/use-atom-command";
import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
+import { DeviceLoadingView } from "./DeviceLoadingView";
import { DeviceSetup } from "./DeviceSetup";
import { DeviceToolsPanel } from "./DeviceToolsPanel";
import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
-const NEW_DEVICE_VALUE = "__new__";
-
const platformLabel = (platform: DevicePlatform) =>
platform === "ios" ? "iOS Simulators" : "Android Emulators";
const deviceKey = (device: Pick) =>
`${device.hostId}\u0000${device.id}`;
-/**
- * The Device right-panel surface: one open device (from the thread's device
- * sessions) with a picker to switch or boot another. Booting and streaming are
- * server-owned; this panel only asks and renders.
- */
+/** Each surface owns one host/device; only the visible surface streams. */
export function DevicePanel(props: {
readonly mode: PreviewPanelMode;
readonly threadRef: ScopedThreadRef;
- /** `null` renders the picker with nothing open. */
- readonly deviceId: string | null;
+ readonly surface: Extract;
readonly visible: boolean;
readonly onDismissSetup: () => void;
}) {
@@ -69,7 +54,8 @@ export function DevicePanel(props: {
const open = useAtomCommand(deviceEnvironment.open);
const close = useAtomCommand(deviceEnvironment.close);
const [operationError, setOperationError] = useState(null);
- const [pendingDeviceKey, setPendingDeviceKey] = useState(null);
+ const [pendingDevice, setPendingDevice] = useState(null);
+ const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;
const [handle, setHandle] = useState(null);
const [toolsOpen, setToolsOpen] = useState(false);
const [axOverlay, setAxOverlay] = useState(false);
@@ -87,10 +73,13 @@ export function DevicePanel(props: {
() => state.sessions.filter((session) => session.threadId === threadId),
[state.sessions, threadId],
);
- const activeSession =
- (props.deviceId
- ? sessions.find((session) => session.deviceId === props.deviceId)
- : undefined) ?? sessions.at(-1);
+ const activeSession = props.surface.target
+ ? sessions.find(
+ (session) =>
+ session.deviceId === props.surface.target?.deviceId &&
+ session.hostId === props.surface.target.hostId,
+ )
+ : undefined;
const activeDevice = activeSession
? state.devices.find(
(device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,
@@ -99,51 +88,67 @@ export function DevicePanel(props: {
const grouped = useMemo(() => groupDevices(state), [state]);
- const selectDevice = useCallback(
- async (value: string) => {
- if (value === NEW_DEVICE_VALUE) return;
- const device = state.devices.find((candidate) => deviceKey(candidate) === value);
- if (!device) return;
- setOperationError(null);
- setPendingDeviceKey(value);
- try {
- const result = await open({
- environmentId,
- input: {
- threadId,
- hostId: device.hostId,
- deviceId: device.id,
- platform: device.platform,
- },
- });
- if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
- } finally {
- setPendingDeviceKey(null);
- }
- },
- [environmentId, open, state.devices, threadId],
- );
-
- const closeActive = useCallback(
- (powerOff: boolean) => {
- if (!activeSession) return;
- setOperationError(null);
- void close({
+ const selectDevice = async (value: string) => {
+ const device = state.devices.find((candidate) => deviceKey(candidate) === value);
+ if (!device) return;
+ setOperationError(null);
+ setPendingDevice(device);
+ try {
+ const result = await open({
environmentId,
- input: { threadId, deviceId: activeSession.deviceId, shutdown: powerOff },
- }).then((result) => {
- if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
+ input: {
+ threadId,
+ hostId: device.hostId,
+ deviceId: device.id,
+ platform: device.platform,
+ },
});
- },
- [activeSession, close, environmentId, threadId],
- );
+ if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
+ else
+ useRightPanelStore.getState().openDevice(props.threadRef, {
+ hostId: result.value.hostId,
+ deviceId: result.value.deviceId,
+ platform: device.platform,
+ name: device.name,
+ });
+ } finally {
+ setPendingDevice(null);
+ }
+ };
+
+ const closeActive = (powerOff: boolean) => {
+ if (!powerOff) {
+ useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
+ return;
+ }
+ if (!activeSession) return;
+ setOperationError(null);
+ void close({
+ environmentId,
+ input: {
+ threadId,
+ hostId: activeSession.hostId,
+ deviceId: activeSession.deviceId,
+ shutdown: powerOff,
+ },
+ }).then((result) => {
+ if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
+ else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
+ });
+ };
const bootingDevices =
state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];
- const hostReady = state.hostStatus === "ready";
- const hostBusy = state.hostStatus === "installing" || state.hostStatus === "starting";
+ const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready");
+ const hostBusy =
+ !hostReady &&
+ Object.values(state.hostStatuses).some(
+ (host) => host.status === "installing" || host.status === "starting",
+ );
const unavailablePlatforms = state.hosts.flatMap((host) =>
- host.platforms.filter((platform) => !platform.available),
+ host.platforms
+ .filter((platform) => !platform.available)
+ .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })),
);
if (loaded && (!state.onboardingCompleted || hostDisabled)) {
@@ -164,63 +169,11 @@ export function DevicePanel(props: {
return (
-
{
- if (value !== null) void selectDevice(value);
- }}
- disabled={!loaded || hostBusy || hostDisabled || pendingDeviceKey !== null}
- >
-
-
- {activeDevice ? (
-
-
- {activeDevice.name}
- {activeDevice.version}
-
- ) : (
-
- {hostBusy
- ? state.hostStatus === "installing"
- ? "Installing device tools…"
- : "Starting device hub…"
- : "Choose a device"}
-
- )}
-
-
-
- {grouped.map((group) => (
-
- {platformLabel(group.platform)}
- {group.devices.map((device) => (
-
-
-
-
- {device.booted ? device.name : `Start ${device.name}`}
-
-
- {device.version}
-
-
-
- ))}
-
- ))}
- {grouped.length === 0 ? (
-
- {loaded ? "No devices found" : "Loading…"}
-
- ) : null}
-
-
+
+ {props.surface.target
+ ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}`
+ : (pendingDevice?.name ?? "Choose a device")}
+
{activeDevice ? (
<>
host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`}
deviceId={activeDevice.id}
+ hostId={activeDevice.hostId}
visible={props.visible}
axOverlay={axOverlay}
onHandle={setHandle}
@@ -330,6 +286,25 @@ export function DevicePanel(props: {
/>
) : null}
>
+ ) : pendingDevice || hostBusy || !loaded ? (
+ host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}`
+ : ""
+ }
+ stage="opening"
+ message={
+ pendingDevice
+ ? pendingDevice.booted
+ ? "Opening device…"
+ : "Starting device…"
+ : state.hostStatus === "installing"
+ ? "Installing device support…"
+ : "Finding devices…"
+ }
+ />
) : (
- {grouped.length === 0 || hostBusy || pendingDeviceKey ? (
+ {grouped.length === 0 ? (
<>
- {hostBusy || pendingDeviceKey ? (
-
- ) : (
-
- )}
+
{state.hostStatus === "failed"
? (state.hostStatusDetail ?? "The device hub failed to start.")
- : pendingDeviceKey
- ? "Booting device… this can take a minute."
- : hostBusy
- ? state.hostStatus === "installing"
- ? "Installing device tools…"
- : "Starting the device hub…"
- : !loaded
- ? "Connecting…"
- : grouped.length === 0
- ? "No simulators or emulators were found on this environment."
- : "Choose a device to open."}
+ : "No simulators or emulators were found on this environment."}
>
) : null}
@@ -380,7 +341,7 @@ export function DevicePanel(props: {
}
title={device.name}
- description={`${device.version} · ${device.booted ? "Running" : "Stopped"}`}
+ description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`}
disabled={pendingDeviceKey !== null}
aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}
onClick={() => void selectDevice(deviceKey(device))}
@@ -418,15 +379,6 @@ export function DevicePanel(props: {
Refresh devices
) : null}
- {unavailablePlatforms.length > 0 && hostReady ? (
-
- {unavailablePlatforms.map((platform) => (
-
- {platform.platform === "ios" ? "iOS" : "Android"}: {platform.reason}
-
- ))}
-
- ) : null}
)}
diff --git a/apps/web/src/components/device/DeviceStreamView.test.tsx b/apps/web/src/components/device/DeviceStreamView.test.tsx
index a46e5ed7b7b8..9c8a1197926a 100644
--- a/apps/web/src/components/device/DeviceStreamView.test.tsx
+++ b/apps/web/src/components/device/DeviceStreamView.test.tsx
@@ -36,6 +36,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () =>
);
const view = (visible: boolean) => (
void;
readonly onScreen?: (screen: DeviceScreenSize | null) => void;
}) {
- const access = useDeviceHubAccess(props.environmentId);
+ const access = useDeviceHubAccess(props.environmentId, props.hostId);
const canvasRef = useRef(null);
const clientRef = useRef(null);
const [status, setStatus] = useState("connecting");
@@ -314,12 +317,14 @@ export function DeviceStreamView(props: {
) : null}
{status !== "streaming" ? (
-
- {status === "connecting" ?
: null}
-
{status === "error" ? (detail ?? "Stream failed.") : "Connecting to device…"}
- {status === "connecting" && detail ? (
-
{detail}
- ) : null}
+
+
) : null}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx
index 9ce3b2126c0e..4ef218430ae1 100644
--- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx
+++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx
@@ -105,6 +105,7 @@ const deviceState = (overrides: Partial = {}): DeviceService
},
],
hostStatus: "ready",
+ hostStatuses: {},
devices: [],
sessions: [],
onboardingCompleted: false,
diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts
index ad1788f4cfe9..e2299511b87c 100644
--- a/apps/web/src/rightPanelStore.test.ts
+++ b/apps/web/src/rightPanelStore.test.ts
@@ -21,6 +21,87 @@ beforeEach(() => {
});
describe("rightPanelStore", () => {
+ it("gives each host/device its own tab and preserves renamed tabs", () => {
+ const store = useRightPanelStore.getState();
+ const android = {
+ hostId: "nucbox",
+ deviceId: "emulator-5580",
+ name: "Pixel",
+ platform: "android",
+ } as const;
+ const ios = { hostId: "macmini", deviceId: "ios-1", name: "iPhone", platform: "ios" } as const;
+ store.open(refA, "device");
+ store.openDevice(refA, android);
+ store.open(refA, "device");
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
+ ).toHaveLength(2);
+ store.openDevice(refA, ios);
+ let state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
+ expect(state.surfaces.map((surface) => surface.id)).toEqual([
+ "device:nucbox:emulator-5580",
+ "device:macmini:ios-1",
+ ]);
+ store.renameDevice(refA, "device:nucbox:emulator-5580", "Android test");
+ store.openDevice(refA, android);
+ state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
+ expect(state.surfaces).toHaveLength(2);
+ expect(state.surfaces[0]).toMatchObject({ title: "Android test", target: android });
+ expect(state.activeSurfaceId).toBe("device:nucbox:emulator-5580");
+ store.closeSurface(refA, state.activeSurfaceId!);
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
+ ).toEqual([expect.objectContaining({ target: ios })]);
+ });
+
+ it("does not collide when two hosts expose the same device id", () => {
+ const store = useRightPanelStore.getState();
+ const device = { deviceId: "emulator-5554", name: "Pixel", platform: "android" } as const;
+ store.openDevice(refA, { ...device, hostId: "a:b" });
+ store.openDevice(refA, { ...device, hostId: "a" });
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
+ ).toHaveLength(2);
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces,
+ ).toHaveLength(0);
+ });
+
+ it.each(["one", "all", "others", "right"])(
+ "keeps device tabs dismissed across reload after closing %s",
+ (mode) => {
+ const store = useRightPanelStore.getState();
+ const target = {
+ hostId: "nucbox",
+ deviceId: "emulator-5580",
+ name: "Pixel",
+ platform: "android",
+ } as const;
+ store.open(refA, "files");
+ store.openDevice(refA, target);
+ if (mode === "one") store.closeSurface(refA, "device:nucbox:emulator-5580");
+ if (mode === "all") store.closeAllSurfaces(refA);
+ if (mode === "others") store.closeOtherSurfaces(refA, "files");
+ if (mode === "right") store.closeSurfacesToRight(refA, "files");
+ const persisted = JSON.parse(
+ JSON.stringify({ byThreadKey: useRightPanelStore.getState().byThreadKey }),
+ );
+ useRightPanelStore.setState(migratePersistedRightPanelState(persisted));
+ store.openDevice(refA, target, true);
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some(
+ (surface) => surface.kind === "device",
+ ),
+ ).toBe(false);
+ store.openDevice(refA, target);
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some(
+ (surface) => surface.kind === "device",
+ ),
+ ).toBe(true);
+ },
+ );
+
const completedDiff = { id: "diff", kind: "diff" } as const;
const linkedPullRequest = pullRequestSurface({
projectId: "project-a",
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index 293733b5b3d9..c44c106c68c8 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -32,15 +32,17 @@ const RIGHT_PANEL_KINDS = [
] as const;
export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
+export interface DeviceTabTarget {
+ hostId: string;
+ deviceId: string;
+ platform: "ios" | "android";
+ name: string;
+}
+
export type RightPanelSurface =
| { id: `browser:${string}`; kind: "preview"; resourceId: string }
| { id: "browser:new"; kind: "preview"; resourceId: null }
- /**
- * One Device tab per thread. The tab is the surface; which device it shows
- * comes from the thread's server-side device sessions, so an agent opening a
- * device from another client lands in the same tab.
- */
- | { id: "device"; kind: "device" }
+ | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string }
| {
id: `terminal:${string}`;
kind: "terminal";
@@ -90,7 +92,7 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
// v10 keys pull-request surfaces by reference instead of a singleton tab.
// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.
// v12 adds the device surface.
-const RIGHT_PANEL_STORAGE_VERSION = 12;
+const RIGHT_PANEL_STORAGE_VERSION = 13;
/** A fixed workspace-level ref: each PR surface carries its own real environment. */
export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
@@ -108,6 +110,7 @@ export interface ThreadRightPanelState {
isOpen: boolean;
activeSurfaceId: string | null;
surfaces: RightPanelSurface[];
+ dismissedDeviceSurfaceIds?: string[];
}
interface RightPanelStoreState {
@@ -128,6 +131,8 @@ interface RightPanelStoreState {
ref: ScopedThreadRef,
kind: Exclude,
) => void;
+ openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void;
+ renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void;
openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
@@ -281,7 +286,12 @@ const updateThread = (
): Record => {
const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
const next = updater(current);
- if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) {
+ if (
+ !next.isOpen &&
+ next.activeSurfaceId === null &&
+ next.surfaces.length === 0 &&
+ !next.dismissedDeviceSurfaceIds?.length
+ ) {
if (!(threadKey in byThreadKey)) return byThreadKey;
const { [threadKey]: _removed, ...rest } = byThreadKey;
return rest;
@@ -306,7 +316,25 @@ const userAction = (
threadKey: string,
updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
): Partial => ({
- byThreadKey: updateThread(state.byThreadKey, threadKey, updater),
+ byThreadKey: updateThread(state.byThreadKey, threadKey, (current) => {
+ const next = updater(current);
+ const removed = current.surfaces.filter(
+ (surface) =>
+ surface.kind === "device" &&
+ surface.target &&
+ !next.surfaces.some((entry) => entry.id === surface.id),
+ );
+ if (removed.length === 0) return next;
+ return {
+ ...next,
+ dismissedDeviceSurfaceIds: [
+ ...new Set([
+ ...(next.dismissedDeviceSurfaceIds ?? []),
+ ...removed.map((surface) => surface.id),
+ ]),
+ ],
+ };
+ }),
userActionRevisionByThreadKey: {
...state.userActionRevisionByThreadKey,
[threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
@@ -426,7 +454,22 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
// first survivor instead of rendering an open empty panel.
const activeSurfaceId =
persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null);
- return [threadKey, { isOpen, surfaces, activeSurfaceId }];
+ return [
+ threadKey,
+ {
+ isOpen,
+ surfaces,
+ activeSurfaceId,
+ ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds)
+ ? {
+ dismissedDeviceSurfaceIds:
+ validThreadState.dismissedDeviceSurfaceIds.filter(
+ (id): id is string => typeof id === "string",
+ ),
+ }
+ : {}),
+ },
+ ];
}),
)
: {};
@@ -472,6 +515,40 @@ export const useRightPanelStore = create()(
return upsertSurface(current, singletonSurface(kind));
}),
),
+ openDevice: (ref, target, automatic = false) =>
+ set((state) =>
+ (automatic ? automaticUpdate : userAction)(state, scopedThreadKey(ref), (current) => {
+ const id =
+ `device:${encodeURIComponent(target.hostId)}:${encodeURIComponent(target.deviceId)}` as const;
+ if (automatic && current.dismissedDeviceSurfaceIds?.includes(id)) return current;
+ const surface: RightPanelSurface = { id, kind: "device", target };
+ const existing = current.surfaces.find((entry) => entry.id === id);
+ const surfaces = existing
+ ? current.surfaces.filter((entry) => entry.id !== "device")
+ : current.surfaces.map((entry) => (entry.id === "device" ? surface : entry));
+ return upsertSurface(
+ {
+ ...current,
+ surfaces,
+ dismissedDeviceSurfaceIds: (current.dismissedDeviceSurfaceIds ?? []).filter(
+ (entry) => entry !== id,
+ ),
+ },
+ existing ?? surface,
+ );
+ }),
+ ),
+ renameDevice: (ref, surfaceId, title) =>
+ set((state) =>
+ userAction(state, scopedThreadKey(ref), (current) => ({
+ ...current,
+ surfaces: current.surfaces.map((surface) =>
+ surface.id === surfaceId && surface.kind === "device"
+ ? { ...surface, title: title.trim() || surface.target?.name || "Device" }
+ : surface,
+ ),
+ })),
+ ),
openBrowser: (ref, tabId) =>
set((state) =>
userAction(state, scopedThreadKey(ref), (current) => {
diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts
index b9c1adf86cdd..9a42cb0b1305 100644
--- a/apps/web/src/state/device.ts
+++ b/apps/web/src/state/device.ts
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { useAtomValue } from "@effect/atom-react";
import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";
import {
@@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt
const EMPTY_DEVICE_STATE: DeviceServiceState = {
hosts: [],
- hostStatus: "idle",
+ hostStatus: "disabled",
+ hostStatuses: {},
devices: [],
sessions: [],
onboardingCompleted: false,
@@ -54,11 +56,20 @@ const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) =>
.pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)),
);
-export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null {
+export function useDeviceHubAccess(
+ environmentId: EnvironmentId | null,
+ hostId = "local",
+): DeviceHubAccess | null {
const result = useAtomValue(
environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId),
);
- return AsyncResult.isSuccess(result) ? result.value : null;
+ return useMemo(
+ () =>
+ AsyncResult.isSuccess(result)
+ ? { ...result.value, query: { ...result.value.query, hostId } }
+ : null,
+ [result, hostId],
+ );
}
const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial()).pipe(
diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts
index 309d156430fa..895a954e2e40 100644
--- a/packages/contracts/src/device.ts
+++ b/packages/contracts/src/device.ts
@@ -95,6 +95,13 @@ export const DeviceServiceState = Schema.Struct({
hosts: Schema.Array(DeviceHostSummary),
hostStatus: DeviceHostStatus,
hostStatusDetail: Schema.optional(Schema.String),
+ hostStatuses: Schema.Record(
+ DeviceHostId,
+ Schema.Struct({
+ status: DeviceHostStatus,
+ detail: Schema.optional(Schema.String),
+ }),
+ ),
devices: Schema.Array(DeviceSummary),
sessions: Schema.Array(DeviceSession),
bootingDevices: Schema.optional(
@@ -129,6 +136,7 @@ export const DeviceOpenInput = Schema.Struct({
export type DeviceOpenInput = typeof DeviceOpenInput.Type;
export const DeviceCloseInput = Schema.Struct({
+ hostId: Schema.optional(DeviceHostId),
threadId: ThreadId,
/** Omit to close every device session for the thread. */
deviceId: Schema.optional(DeviceId),
From 7734c6d71b6dd5df9c9b4856cff8e419b8837fe8 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 10 Sep 2026 11:59:33 -0700
Subject: [PATCH 14/15] feat(devices): target concurrent agent sessions across
hosts (#10855)
---
apps/server/src/device/AgentDeviceShim.ts | 28 +++++-
.../src/device/AgentDeviceTarget.test.ts | 89 ++++++++++++++++++
apps/server/src/device/AgentDeviceTarget.ts | 43 +++++++++
apps/server/src/device/DeviceService.test.ts | 4 +-
apps/server/src/device/DeviceService.ts | 90 ++++++++++++++++++-
apps/server/src/mcp/McpDeviceToolkit.test.ts | 49 +++++++++-
.../src/mcp/toolkits/device/handlers.test.ts | 13 +++
.../src/mcp/toolkits/device/handlers.ts | 66 +++++++++++---
apps/server/src/mcp/toolkits/device/tools.ts | 5 +-
.../provider/CodexDeveloperInstructions.ts | 2 +-
.../src/provider/Layers/ProviderService.ts | 35 ++------
11 files changed, 376 insertions(+), 48 deletions(-)
create mode 100644 apps/server/src/device/AgentDeviceTarget.test.ts
create mode 100644 apps/server/src/device/AgentDeviceTarget.ts
diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts
index c2a28127e31d..e3f92de492f3 100644
--- a/apps/server/src/device/AgentDeviceShim.ts
+++ b/apps/server/src/device/AgentDeviceShim.ts
@@ -1,3 +1,4 @@
+// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.
/**
* A directory holding an `agent-device` launcher that runs the pinned install
* with the server's Node. Prepended to provider subprocess PATHs so the agent
@@ -22,11 +23,34 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio
const shimDir = path.join(input.stateDir, SHIM_DIR);
yield* fs.makeDirectory(shimDir, { recursive: true });
const node = process.execPath;
+ const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");
+ yield* fs.writeFileString(
+ launcherPath,
+ `import { spawn } from "node:child_process";
+const args = process.argv.slice(2);
+const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]);
+const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); };
+if (!informational && !(hasValue("--config") && hasValue("--session"))) {
+ console.error("Call device_open first and include its --config and --session flags.");
+ process.exit(1);
+}
+const env = { ...process.env };
+delete env.AGENT_DEVICE_DAEMON_BASE_URL;
+delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
+delete env.AGENT_DEVICE_CONFIG;
+const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env });
+child.on("error", error => { console.error(error.message); process.exitCode = 1; });
+child.on("exit", code => { process.exitCode = code ?? 1; });
+`,
+ );
if (platform === "win32") {
- const script = `@echo off\r\n"${node}" "${entryPath}" %*\r\n`;
+ const script = `@echo off\r\n"${node}" "${launcherPath}" %*\r\n`;
yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script);
} else {
- const script = `#!/bin/sh\nexec "${node}" "${entryPath}" "$@"\n`;
+ const command = [node, launcherPath]
+ .map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'")
+ .join(" ");
+ const script = `#!/bin/sh\nexec ${command} "$@"\n`;
const shimPath = path.join(shimDir, "agent-device");
yield* fs.writeFileString(shimPath, script);
yield* fs.chmod(shimPath, 0o755);
diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts
new file mode 100644
index 000000000000..3552beaf3cde
--- /dev/null
+++ b/apps/server/src/device/AgentDeviceTarget.test.ts
@@ -0,0 +1,89 @@
+// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.
+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { describe, expect, it } from "@effect/vitest";
+import * as NodeChildProcess from "node:child_process";
+import * as NodeUtil from "node:util";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts";
+import {
+ agentDeviceConfigPath,
+ agentDeviceSession,
+ writeAgentDeviceConfig,
+} from "./AgentDeviceTarget.ts";
+
+const exec = NodeUtil.promisify(NodeChildProcess.execFile);
+
+describe("host-bound agent commands", () => {
+ it.effect("runs two hosts concurrently and only updates the reconnected host", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" });
+ const platform = yield* HostProcessPlatform;
+ const dir = path.join(
+ temp,
+ platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`",
+ );
+ yield* fs.makeDirectory(dir);
+ const entryPath = path.join(dir, "cli.mjs");
+ yield* fs.writeFileString(
+ entryPath,
+ `import { readFileSync } from 'node:fs';
+const args = process.argv.slice(2);
+console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));
+if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
+ );
+ const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
+ const files = ["mini", "android"].map((host) => agentDeviceConfigPath(dir, host, path));
+ for (const [index, file] of files.entries())
+ yield* writeAgentDeviceConfig(file, {
+ baseUrl: `http://127.0.0.1:${1000 + index}`,
+ token: `token-${index}`,
+ entryPath,
+ });
+ const invoke = (file: string) =>
+ exec(
+ platform === "win32" ? process.execPath : path.join(shim, "agent-device"),
+ [
+ ...(platform === "win32" ? [path.join(shim, "agent-device-launcher.mjs")] : []),
+ "snapshot",
+ "--config",
+ file,
+ "--session",
+ "test-session",
+ ],
+ { env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } },
+ ).then((result) => JSON.parse(result.stdout));
+ expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([
+ { daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" },
+ { daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" },
+ ]);
+ const second = yield* fs.readFileString(files[1]!);
+ yield* writeAgentDeviceConfig(files[0]!, {
+ baseUrl: "http://127.0.0.1:2000",
+ token: "new",
+ entryPath,
+ });
+ expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
+ expect(yield* fs.readFileString(files[1]!)).toBe(second);
+ expect(agentDeviceSession("thread", "mini", "same-id")).not.toBe(
+ agentDeviceSession("thread", "android", "same-id"),
+ );
+ for (const args of [
+ ["snapshot"],
+ ["snapshot", "--config", files[0]!],
+ ["snapshot", "--config", "help"],
+ ["snapshot", "--config", files[0]!, "--session"],
+ ]) {
+ yield* Effect.promise(() =>
+ expect(
+ exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]),
+ ).rejects.toThrow("Call device_open first"),
+ );
+ }
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+});
diff --git a/apps/server/src/device/AgentDeviceTarget.ts b/apps/server/src/device/AgentDeviceTarget.ts
new file mode 100644
index 000000000000..379649b1257c
--- /dev/null
+++ b/apps/server/src/device/AgentDeviceTarget.ts
@@ -0,0 +1,43 @@
+import * as NodeCrypto from "node:crypto";
+import * as Schema from "effect/Schema";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import type { AgentDeviceEndpoint } from "./DeviceHost.ts";
+
+const encodeEndpoint = Schema.encodeEffect(
+ Schema.fromJsonString(
+ Schema.Struct({ daemonBaseUrl: Schema.String, daemonAuthToken: Schema.String }),
+ ),
+);
+
+const key = (value: string) =>
+ NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24);
+
+/** A stable file per host lets forwarded endpoints change without retargeting other commands. */
+export const agentDeviceConfigPath = (stateDir: string, hostId: string, path: Path.Path) =>
+ path.join(stateDir, "device", "hosts", `${key(hostId)}.json`);
+
+export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) =>
+ `t3-${key(JSON.stringify([threadId, hostId, deviceId]))}`;
+
+export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (
+ file: string,
+ endpoint: AgentDeviceEndpoint,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
+ const content = yield* encodeEndpoint({
+ daemonBaseUrl: endpoint.baseUrl,
+ daemonAuthToken: endpoint.token,
+ });
+ if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return;
+ const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" });
+ yield* Effect.gen(function* () {
+ yield* fs.chmod(temporary, 0o600);
+ yield* fs.writeFileString(temporary, content);
+ yield* fs.rename(temporary, file);
+ }).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore)));
+});
diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts
index 49a8545bfd6d..b45db236fab6 100644
--- a/apps/server/src/device/DeviceService.test.ts
+++ b/apps/server/src/device/DeviceService.test.ts
@@ -16,7 +16,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ServerSettingsService } from "../serverSettings.ts";
import * as DeviceHost from "./DeviceHost.ts";
-import { type DeviceService, make, stateStream } from "./DeviceService.ts";
+import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts";
const baseState: DeviceServiceState = {
hosts: [],
@@ -108,7 +108,7 @@ const fixture = Effect.fn("fixture")(function* (
starts.push("stop");
}),
};
- const service = yield* make.pipe(
+ const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe(
Effect.provideService(DeviceHost.DeviceHost, host),
Effect.provideService(
ServerSettingsService,
diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts
index 597e1a5d2b10..866663dbf21c 100644
--- a/apps/server/src/device/DeviceService.ts
+++ b/apps/server/src/device/DeviceService.ts
@@ -33,6 +33,15 @@ import {
LOCAL_DEVICE_HOST_ID,
type ThreadId,
} from "@t3tools/contracts";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import { ensureAgentDevice } from "./DeviceToolchain.ts";
+import * as ServerConfig from "../config.ts";
+import {
+ agentDeviceConfigPath,
+ agentDeviceSession,
+ writeAgentDeviceConfig,
+} from "./AgentDeviceTarget.ts";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
@@ -49,6 +58,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import * as ServerSettings from "../serverSettings.ts";
import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
+import * as ProcessRunner from "../processRunner.ts";
import * as DeviceHost from "./DeviceHost.ts";
import * as LocalDeviceHost from "./LocalDeviceHost.ts";
@@ -94,6 +104,12 @@ export interface DeviceAgentReadiness extends DeviceReadiness {
export class DeviceService extends Context.Service<
DeviceService,
{
+ readonly agentCli: Effect.Effect;
+ readonly agentTarget: (input: {
+ threadId: ThreadId;
+ hostId: DeviceHostId;
+ deviceId: DeviceId;
+ }) => Effect.Effect, DeviceError>;
readonly state: Effect.Effect;
readonly subscribe: Effect.Effect, never, Scope.Scope>;
readonly configure: (
@@ -138,6 +154,16 @@ const vendorPrefix = (platform: DevicePlatform) =>
export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
hosts: ReadonlyMap,
+ configureAgent: (
+ hostId: DeviceHostId,
+ ready: DeviceHost.DeviceHostAgentReady,
+ ) => Effect.Effect = (hostId) =>
+ Effect.fail(
+ new DeviceHostUnavailableError({
+ hostId,
+ reason: "Agent configuration is unavailable in this device service.",
+ }),
+ ),
) {
const settings = yield* ServerSettings.ServerSettingsService;
const lifecycleLock = yield* Semaphore.make(1);
@@ -733,6 +759,29 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
);
return DeviceService.of({
+ agentCli: Effect.fail(
+ new DeviceHostUnavailableError({
+ hostId: LOCAL_DEVICE_HOST_ID,
+ reason: "Agent CLI installation is unavailable in this device service.",
+ }),
+ ),
+ agentTarget: (input) =>
+ Effect.gen(function* () {
+ const ready = yield* agentReadinessIfSupported(input.hostId);
+ if (!ready)
+ return yield* new DeviceHostUnavailableError({
+ hostId: input.hostId,
+ reason:
+ "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
+ });
+ const configPath = yield* configureAgent(input.hostId, ready);
+ return [
+ "--config",
+ configPath,
+ "--session",
+ agentDeviceSession(input.threadId, input.hostId, input.deviceId),
+ ];
+ }),
state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
subscribe: PubSub.subscribe(statePubSub),
configure,
@@ -751,9 +800,46 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
});
});
+/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
- const host = yield* DeviceHost.DeviceHost;
- return yield* makeWithHosts(new Map([[host.id, host]]));
+ const localHost = yield* DeviceHost.DeviceHost;
+ const config = yield* ServerConfig.ServerConfig;
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const runner = yield* ProcessRunner.ProcessRunner;
+ const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => {
+ const file = agentDeviceConfigPath(config.stateDir, hostId, path);
+ return writeAgentDeviceConfig(file, ready.agentDevice).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ Effect.mapError(
+ (cause) =>
+ new DeviceOperationError({
+ operation: "configure agent",
+ reason: "settings_failed",
+ cause,
+ }),
+ ),
+ Effect.as(file),
+ );
+ });
+ return {
+ ...service,
+ agentCli: ensureAgentDevice(config.baseDir).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ Effect.provideService(ProcessRunner.ProcessRunner, runner),
+ Effect.map((tool) => tool.entryPath),
+ Effect.mapError(
+ (error) =>
+ new DeviceOperationError({
+ operation: "install agent CLI",
+ reason: "command_failed",
+ cause: error,
+ }),
+ ),
+ ),
+ };
});
export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts
index 32f919cbb682..24097a92faac 100644
--- a/apps/server/src/mcp/McpDeviceToolkit.test.ts
+++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts
@@ -1,10 +1,16 @@
import { expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import {
+ DeviceHostUnavailableError,
+ EnvironmentId,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { McpSchema, McpServer } from "effect/unstable/ai";
+import * as ServerConfig from "../config.ts";
import * as DeviceService from "../device/DeviceService.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
@@ -85,11 +91,14 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
sessionsForThread: () => Effect.succeed([]),
screenshot: () => Effect.succeed({ device, png }),
close: () => Effect.void,
+ agentCli: Effect.succeed("/cli"),
+ agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),
});
const TestLayer = McpHttpServer.DeviceToolkitRegistrationLive.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provideMerge(DeviceServiceMock),
+ Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })),
Layer.provide(NodeServices.layer),
);
@@ -126,3 +135,41 @@ it.effect("registers the device tools and returns the screenshot as image conten
}),
).pipe(Effect.provide(TestLayer)),
);
+
+it.effect("rejects unavailable agent access before booting or opening a device", () => {
+ const unavailable = Layer.mock(DeviceService.DeviceService)({
+ list: Effect.succeed(state),
+ agentTarget: () =>
+ Effect.fail(
+ new DeviceHostUnavailableError({ hostId: "local", reason: "Agent access is disabled." }),
+ ),
+ open: () => Effect.die("Must not boot or register a device when agent access fails"),
+ });
+ return Effect.gen(function* () {
+ const server = yield* McpServer.McpServer;
+ const result = yield* server
+ .callTool({ name: "device_open", arguments: { platform: "ios" } })
+ .pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])),
+ Effect.provideService(McpSchema.McpServerClient, client),
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: "text",
+ text: expect.stringContaining("Agent access is disabled."),
+ }),
+ ]),
+ );
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ McpHttpServer.DeviceToolkitRegistrationLive.pipe(
+ Layer.provideMerge(McpServer.McpServer.layer),
+ Layer.provide(unavailable),
+ Layer.provide(NodeServices.layer),
+ ),
+ ),
+ );
+});
diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts
index e37b2a8b22c2..110d4cbba72a 100644
--- a/apps/server/src/mcp/toolkits/device/handlers.test.ts
+++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts
@@ -30,6 +30,19 @@ describe("device tool helpers", () => {
expect(text).toContain("XCTest runner");
});
+ it("uses the absolute launcher in every quick-start command", () => {
+ const text = agentDeviceQuickStart(
+ device,
+ ["--session", "thread-1", "--config", "/tmp/host.json"],
+ "/tmp/t3 tools/agent-device",
+ );
+ expect(text).toContain(
+ "'/tmp/t3 tools/agent-device' snapshot -i --session thread-1 --config /tmp/host.json",
+ );
+ expect(text).not.toContain(" agent-device ");
+ expect(text).not.toContain("is on PATH");
+ });
+
it("reads PNG dimensions from the IHDR chunk", () => {
const png = new Uint8Array(24);
new DataView(png.buffer).setUint32(0, 0x89504e47);
diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts
index 5ad57031306e..3bc89d22bc60 100644
--- a/apps/server/src/mcp/toolkits/device/handlers.ts
+++ b/apps/server/src/mcp/toolkits/device/handlers.ts
@@ -8,6 +8,10 @@ import {
LOCAL_DEVICE_HOST_ID,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
+import * as Path from "effect/Path";
+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { ServerConfig } from "../../../config.ts";
+import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts";
import * as DeviceService from "../../../device/DeviceService.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
@@ -26,24 +30,37 @@ export function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray
+ /^[a-zA-Z0-9_./:-]+$/.test(arg) ? arg : "'" + arg.replaceAll("'", "'\"'\"'") + "'",
+ )
+ .join(" ");
const platformNotes =
device.platform === "ios"
? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast."
: "The Android snapshot helper installs itself on first use.";
return [
`The user is watching ${device.name} (${device.version}) in the Device panel.`,
- `Drive it with the agent-device CLI, which is on PATH and already connected to this environment. Always pass ${target}.`,
+ `Drive it with ${executable}. Use this exact executable path; login shells may reset PATH. Always pass ${target}.`,
"Typical loop:",
- ` agent-device open ${target} # or: open `,
- ` agent-device snapshot -i ${target} # accessibility tree with @eN refs`,
- ` agent-device click @e3 ${target}`,
- ` agent-device fill @e5 "text" ${target}`,
- ` agent-device screenshot /tmp/shot.png ${target} # or call device_screenshot`,
- ` agent-device install ${target}`,
- "Prefer snapshot refs over coordinates. Run `agent-device help` for workflow guides and `agent-device --help` for flags.",
+ ` ${executable} open ${target} # or: open `,
+ ` ${executable} snapshot -i ${target} # accessibility tree with @eN refs`,
+ ` ${executable} click @e3 ${target}`,
+ ` ${executable} fill @e5 "text" ${target}`,
+ ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`,
+ ` ${executable} install ${target}`,
+ `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} --help for flags.`,
"Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.",
+ "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.",
+ "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.",
platformNotes,
].join("\n");
}
@@ -136,6 +153,12 @@ const handlers = {
});
}
const target = yield* pickDevice(state.devices, input);
+ // Resolve consent and agent connectivity before booting or registering a session.
+ const agentArgs = yield* devices.agentTarget({
+ threadId: scope.threadId,
+ hostId: target.hostId,
+ deviceId: target.id,
+ });
const session = yield* devices.open({
threadId: scope.threadId,
hostId: target.hostId,
@@ -147,10 +170,29 @@ const handlers = {
after.devices.find(
(candidate) => candidate.hostId === session.hostId && candidate.id === session.deviceId,
) ?? target;
+ const targetArgs = [...agentDeviceTargetArgs(device), ...agentArgs];
+ const config = yield* ServerConfig;
+ const path = yield* Path.Path;
+ const platform = yield* HostProcessPlatform;
+ const shimDir = yield* ensureAgentDeviceShim({
+ entryPath: yield* devices.agentCli,
+ stateDir: config.stateDir,
+ }).pipe(
+ Effect.mapError(
+ () =>
+ new DeviceToolUnavailableError({
+ reason: "Could not prepare the agent-device launcher.",
+ }),
+ ),
+ );
+ const command = path.join(
+ shimDir,
+ platform === "win32" ? "agent-device.cmd" : "agent-device",
+ );
return {
device,
- agentDevice: { command: "agent-device", targetArgs: agentDeviceTargetArgs(device) },
- quickStart: agentDeviceQuickStart(device),
+ agentDevice: { command, targetArgs },
+ quickStart: agentDeviceQuickStart(device, targetArgs, command),
};
}).pipe(Effect.mapError(toolError)),
device_screenshot: (input) =>
diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts
index e98d6ab64829..58bfed41d940 100644
--- a/apps/server/src/mcp/toolkits/device/tools.ts
+++ b/apps/server/src/mcp/toolkits/device/tools.ts
@@ -8,6 +8,9 @@ import {
DeviceToolTargetInput,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import { ServerConfig } from "../../../config.ts";
import { Tool, Toolkit } from "effect/unstable/ai";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
@@ -48,7 +51,7 @@ const DeviceOpenTool = Tool.make("device_open", {
parameters: DeviceToolOpenInput,
success: DeviceToolOpenResult,
failure: DeviceToolError,
- dependencies,
+ dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig],
})
.annotate(Tool.Title, "Open device")
.annotate(Tool.Readonly, false)
diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts
index 85784d21ca4b..6a7fee351bce 100644
--- a/apps/server/src/provider/CodexDeveloperInstructions.ts
+++ b/apps/server/src/provider/CodexDeveloperInstructions.ts
@@ -16,7 +16,7 @@ const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `
## T3 Code devices
-The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH and already connected: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.
+The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.
`;
export interface T3CodeToolAvailability {
diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts
index f2d94e735b0d..d04dcae7f126 100644
--- a/apps/server/src/provider/Layers/ProviderService.ts
+++ b/apps/server/src/provider/Layers/ProviderService.ts
@@ -54,8 +54,8 @@ import * as Stream from "effect/Stream";
import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import * as ServerConfig from "../../config.ts";
-import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts";
import * as DeviceService from "../../device/DeviceService.ts";
+import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts";
import type * as McpInvocationContext from "../../mcp/McpInvocationContext.ts";
import {
increment,
@@ -254,8 +254,6 @@ export interface ProviderServiceLiveOptions {
* test see whether a credential was requested at all.
*/
readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
- /** Overrides the device host lookup used to build the agent-device environment. */
- readonly deviceReadiness?: DeviceService.DeviceService["Service"]["agentReadinessIfSupported"];
}
interface TurnAnalyticsMetadata {
@@ -484,14 +482,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
);
const issueMcpCredential =
options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
- const deviceReadiness =
- options?.deviceReadiness ??
- (() =>
- Effect.serviceOption(DeviceService.DeviceService).pipe(
- Effect.flatMap((service) =>
- Option.isSome(service) ? service.value.agentReadinessIfSupported() : Effect.succeed(null),
- ),
- ));
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const runtimeEventPubSub = yield* PubSub.unbounded();
@@ -914,26 +904,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
return capabilities;
});
- /**
- * Starting a session with device access also brings the device host up, so
- * the `agent-device` CLI is on the provider's PATH from its first turn. The
- * environment is fixed at spawn time, so a host started later by
- * `device_open` could not reach an already-running agent. Tools install once
- * and the host is idempotent, so this is cheap after the first session;
- * a host that fails to start withholds only the CLI, not the MCP tools.
- */
+ /** Install only the local CLI here. device_open supplies a separate config for each host. */
const hostPlatform = yield* HostProcessPlatform;
const agentDeviceEnvironment = Effect.gen(function* () {
- const readiness = yield* deviceReadiness().pipe(
+ const devices = yield* Effect.serviceOption(DeviceService.DeviceService);
+ if (Option.isNone(devices)) return undefined;
+ const entryPath = yield* devices.value.agentCli.pipe(
Effect.catch((cause) =>
- Effect.logWarning("Device host unavailable; starting session without agent-device", {
- cause,
- }).pipe(Effect.as(null)),
+ Effect.logWarning("Agent device CLI unavailable", { cause }).pipe(Effect.as(null)),
),
);
- if (!readiness) return undefined;
+ if (!entryPath) return undefined;
const shimDir = yield* ensureAgentDeviceShim({
- entryPath: readiness.agentDevice.entryPath,
+ entryPath,
stateDir: serverConfig.stateDir,
}).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
@@ -944,8 +927,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
return {
PATH: shimDir,
PATH_SEPARATOR: hostPlatform === "win32" ? ";" : ":",
- AGENT_DEVICE_DAEMON_BASE_URL: readiness.agentDevice.baseUrl,
- AGENT_DEVICE_DAEMON_AUTH_TOKEN: readiness.agentDevice.token,
AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
} satisfies Record;
});
From d2eeacd8ccde4d8763ca315b4cddf2964db93329 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 10 Sep 2026 11:59:34 -0700
Subject: [PATCH 15/15] feat(devices): connect simulator hosts over SSH
(#10856)
---
apps/server/package.json | 1 +
apps/server/src/auth/RpcAuthorization.ts | 1 +
apps/server/src/device/DeviceActions.test.ts | 1 +
apps/server/src/device/DeviceActions.ts | 2 +-
apps/server/src/device/DeviceHost.ts | 3 +-
.../server/src/device/DeviceMultiHost.test.ts | 54 ++-
apps/server/src/device/DeviceService.test.ts | 1 +
apps/server/src/device/DeviceService.ts | 272 ++++++++---
apps/server/src/device/DeviceToolchain.ts | 4 +-
apps/server/src/device/LocalDeviceHost.ts | 1 +
apps/server/src/device/SshDeviceHost.test.ts | 131 ++++++
apps/server/src/device/SshDeviceHost.ts | 434 ++++++++++++++++++
.../server/src/device/sshDeviceScript.test.ts | 220 +++++++++
apps/server/src/device/sshDeviceScript.ts | 192 ++++++++
apps/server/src/mcp/McpDeviceToolkit.test.ts | 1 +
.../src/mcp/toolkits/device/handlers.ts | 3 +
apps/server/src/ws.ts | 4 +
.../device/DeviceHostAvailability.tsx | 27 ++
.../settings/DeviceHostsSettings.tsx | 334 ++++++++++++++
.../settings/IntegrationsSettings.tsx | 72 ++-
.../src/components/settings/settingsSearch.ts | 6 +
docs/internals/devices.md | 12 +-
docs/user/devices.md | 29 +-
packages/client-runtime/src/state/device.ts | 4 +
packages/contracts/src/device.ts | 24 +-
packages/contracts/src/rpc.ts | 10 +
packages/contracts/src/settings.test.ts | 13 +
packages/contracts/src/settings.ts | 3 +
packages/shared/src/serverSettings.test.ts | 10 +
pnpm-lock.yaml | 3 +
30 files changed, 1794 insertions(+), 78 deletions(-)
create mode 100644 apps/server/src/device/SshDeviceHost.test.ts
create mode 100644 apps/server/src/device/SshDeviceHost.ts
create mode 100644 apps/server/src/device/sshDeviceScript.test.ts
create mode 100644 apps/server/src/device/sshDeviceScript.ts
create mode 100644 apps/web/src/components/device/DeviceHostAvailability.tsx
create mode 100644 apps/web/src/components/settings/DeviceHostsSettings.tsx
diff --git a/apps/server/package.json b/apps/server/package.json
index 7d27d39d2eb3..84a8c6a5988b 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -40,6 +40,7 @@
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
+ "@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 97afa4f40775..d7d1be455fa1 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
+ [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceList]: AuthOrchestrationReadScope,
[WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts
index ed6b5a87335d..c22dcfe86610 100644
--- a/apps/server/src/device/DeviceActions.test.ts
+++ b/apps/server/src/device/DeviceActions.test.ts
@@ -16,6 +16,7 @@ const makeReady = (
) => {
const calls: Call[] = [];
const ready: DeviceHostReady = {
+ nodePath: process.execPath,
hub: { origin: "http://127.0.0.1:1" },
helpers,
run: (command, args, options) => {
diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts
index 12781b31b4b5..509dac526b93 100644
--- a/apps/server/src/device/DeviceActions.ts
+++ b/apps/server/src/device/DeviceActions.ts
@@ -327,7 +327,7 @@ const serveSimPermissions = (
reason: "helper_missing",
});
yield* ready
- .run(process.execPath, [
+ .run(ready.nodePath, [
cli,
"permissions",
input.decision,
diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts
index 8d1bafbc5dfc..34ff2769362e 100644
--- a/apps/server/src/device/DeviceHost.ts
+++ b/apps/server/src/device/DeviceHost.ts
@@ -46,11 +46,12 @@ export interface DeviceHubEndpoint {
export interface AgentDeviceEndpoint {
readonly baseUrl: string;
readonly token: string;
- /** Absolute path of the agent-device entry script for the provider PATH shim. */
+ /** Host-local path of the agent-device entry script. The provider uses a separate local CLI install. */
readonly entryPath: string;
}
export interface DeviceHostReady {
+ readonly nodePath: string;
readonly hub: DeviceHubEndpoint;
/**
* Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub)
diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts
index d586dffa1567..7a40e1e53c72 100644
--- a/apps/server/src/device/DeviceMultiHost.test.ts
+++ b/apps/server/src/device/DeviceMultiHost.test.ts
@@ -1,5 +1,7 @@
import { expect, it } from "@effect/vitest";
import { ThreadId } from "@t3tools/contracts";
+import * as Deferred from "effect/Deferred";
+import * as Fiber from "effect/Fiber";
import * as Effect from "effect/Effect";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ServerSettingsService } from "../serverSettings.ts";
@@ -10,6 +12,7 @@ it.effect("keeps hosts independent when serials collide and another host fails",
Effect.gen(function* () {
const host = (id: string, failed = false): DeviceHost["Service"] => {
const ready = {
+ nodePath: process.execPath,
hub: { origin: `http://${id}` },
agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },
run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
@@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails",
summary: Effect.succeed({
id,
label: id,
- kind: "local",
+ kind: id === "b" ? "ssh" : "local",
hubInstalled: true,
agentDeviceInstalled: true,
- platforms: [{ platform: "android", available: true }],
+ platforms: id === "b" ? [] : [{ platform: "android", available: true }],
}),
platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
ensureReady: () =>
@@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails",
),
);
const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")]));
- const service = yield* makeWithHosts(hosts).pipe(
- Effect.provideService(HttpClient.HttpClient, http),
- );
+ const writeStarted = yield* Deferred.make();
+ const finishWrite = yield* Deferred.make();
+ const order: string[] = [];
+ const service = yield* makeWithHosts(hosts, undefined, () =>
+ Effect.gen(function* () {
+ order.push("write started");
+ yield* Deferred.succeed(writeStarted, undefined);
+ yield* Deferred.await(finishWrite);
+ order.push("write finished");
+ return "/host-config.json";
+ }),
+ ).pipe(Effect.provideService(HttpClient.HttpClient, http));
+ expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull();
const listed = yield* service.list;
expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
expect(listed.hostStatuses.offline?.status).toBe("failed");
@@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails",
expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]);
expect(state.hostStatuses.a?.status).toBe("ready");
expect(state.hostStatuses.offline?.status).toBe("failed");
- yield* service.agentReadinessIfSupported("b");
- expect((yield* service.state).hostStatuses.b?.status).toBe("ready");
+ const targeting = yield* service
+ .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(writeStarted);
+ const replacing = yield* service
+ .withLifecycleLock(
+ Effect.gen(function* () {
+ order.push("replace");
+ hosts.set("b", host("b"));
+ yield* service.refreshHosts;
+ }),
+ )
+ .pipe(Effect.forkChild);
+ yield* Deferred.succeed(finishWrite, undefined);
+ yield* Fiber.join(targeting);
+ yield* Fiber.join(replacing);
+ expect(order).toEqual(["write started", "write finished", "replace"]);
+ const replaced = yield* service.state;
+ expect(replaced.sessions).toEqual([]);
+ expect(replaced.devices.map((device) => device.hostId)).toEqual(["a"]);
+ expect(replaced.hostStatuses.b).toBeUndefined();
+ yield* service.open({ threadId, hostId: "b", deviceId: "emulator-5554", platform: "android" });
+ hosts.delete("b");
+ yield* service.refreshHosts;
+ yield* service.setHostStatus("b", { status: "ready" });
+ expect((yield* service.state).hostStatuses.b).toBeUndefined();
+ expect((yield* service.state).sessions).toEqual([]);
+ yield* service.agentReadinessIfSupported("a");
+ expect((yield* service.state).hostStatuses.a?.status).toBe("ready");
yield* service.configure({ enabled: false });
expect((yield* service.state).hostStatuses).toEqual({});
}).pipe(
diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts
index b45db236fab6..46e159dbfc33 100644
--- a/apps/server/src/device/DeviceService.test.ts
+++ b/apps/server/src/device/DeviceService.test.ts
@@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* (
let booted = false;
let shutDown = false;
const ready: DeviceHost.DeviceHostReady = {
+ nodePath: process.execPath,
hub: { origin: "http://device.test" },
helpers: { serveSimAxSettings: null, serveSimCli: null },
run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts
index 866663dbf21c..a60cda76c648 100644
--- a/apps/server/src/device/DeviceService.ts
+++ b/apps/server/src/device/DeviceService.ts
@@ -30,6 +30,8 @@ import {
type DeviceSession,
type DeviceShutdownInput,
type DeviceSummary,
+ type SshDeviceHostConfig,
+ type DeviceHostSummary,
LOCAL_DEVICE_HOST_ID,
type ThreadId,
} from "@t3tools/contracts";
@@ -60,6 +62,8 @@ import * as ServerSettings from "../serverSettings.ts";
import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as DeviceHost from "./DeviceHost.ts";
+import * as SshDeviceHost from "./SshDeviceHost.ts";
+import * as Exit from "effect/Exit";
import * as LocalDeviceHost from "./LocalDeviceHost.ts";
/** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */
@@ -105,6 +109,9 @@ export class DeviceService extends Context.Service<
DeviceService,
{
readonly agentCli: Effect.Effect;
+ readonly testHost: (
+ config: SshDeviceHostConfig,
+ ) => Effect.Effect;
readonly agentTarget: (input: {
threadId: ThreadId;
hostId: DeviceHostId;
@@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) =>
export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
hosts: ReadonlyMap,
+ testHost: DeviceService["Service"]["testHost"] = (host) =>
+ Effect.fail(
+ new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "SSH probing is unavailable in this device service.",
+ }),
+ ),
configureAgent: (
hostId: DeviceHostId,
ready: DeviceHost.DeviceHostAgentReady,
@@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
const statePubSub = yield* PubSub.unbounded();
const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
+ let publishedHosts = new Map(hosts);
const stateRef = yield* SynchronizedRef.make({
state: {
hosts: initialHosts,
@@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
hostId: DeviceHostId,
status: DeviceServiceState["hostStatuses"][string],
) =>
- publish((state) => ({
- ...state,
- ...(hostId === LOCAL_DEVICE_HOST_ID
- ? { hostStatus: status.status, hostStatusDetail: status.detail }
- : {}),
- hostStatuses: { ...state.hostStatuses, [hostId]: status },
- }));
+ Effect.suspend(() =>
+ !hosts.has(hostId)
+ ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state))
+ : publish((state) => ({
+ ...state,
+ ...(hostId === LOCAL_DEVICE_HOST_ID
+ ? { hostStatus: status.status, hostStatusDetail: status.detail }
+ : {}),
+ hostStatuses: { ...state.hostStatuses, [hostId]: status },
+ })),
+ );
const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
function* (hostId) {
@@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
(error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
),
);
+ if (hosts.get(host.id) !== host)
+ return yield* new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "Host configuration changed. Retry the operation.",
+ });
const { state } = yield* SynchronizedRef.get(stateRef);
if (state.hostStatuses[host.id]?.status !== "ready") {
yield* setHostStatus(host.id, { status: "ready" });
@@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
if (!(yield* readDeviceSettings).enabled) return null;
const host = yield* resolveHost(hostId);
const summary = yield* host.summary;
- if (!summary.platforms.some((platform) => platform.available)) return null;
+ if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
+ return null;
return yield* readiness(host.id);
});
@@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
const host = yield* resolveHost(hostId);
const summary = yield* host.summary;
- if (!summary.platforms.some((platform) => platform.available)) return null;
+ if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
+ return null;
const ready = yield* host
.ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
.pipe(
@@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
});
const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
+ const host = hosts.get(ready.hostId);
const { devices, detail } = yield* fetchDevices(ready);
const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
return yield* lifecycleLock.withPermit(
Effect.gen(function* () {
- if (!(yield* readDeviceSettings).enabled)
+ if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host)
return (yield* SynchronizedRef.get(stateRef)).state;
return yield* publish((state) => ({
...state,
@@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
),
);
}
+ if (hosts.get(host.id) !== host)
+ return yield* new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "Host configuration changed. Retry the operation.",
+ });
const openedAt = DateTime.formatIso(yield* DateTime.now);
const session: DeviceSession = {
threadId: input.threadId,
@@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)),
);
- return DeviceService.of({
- agentCli: Effect.fail(
- new DeviceHostUnavailableError({
- hostId: LOCAL_DEVICE_HOST_ID,
- reason: "Agent CLI installation is unavailable in this device service.",
- }),
- ),
- agentTarget: (input) =>
- Effect.gen(function* () {
- const ready = yield* agentReadinessIfSupported(input.hostId);
- if (!ready)
- return yield* new DeviceHostUnavailableError({
- hostId: input.hostId,
- reason:
- "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
- });
- const configPath = yield* configureAgent(input.hostId, ready);
- return [
- "--config",
- configPath,
- "--session",
- agentDeviceSession(input.threadId, input.hostId, input.deviceId),
- ];
- }),
- state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
- subscribe: PubSub.subscribe(statePubSub),
- configure,
- list,
- open,
- close,
- shutdown,
- detail,
- action,
- screenshot,
- readiness,
- readinessIfSupported,
- agentReadinessIfSupported,
- currentReadiness,
- sessionsForThread,
- });
+ return {
+ ...DeviceService.of({
+ testHost,
+ agentCli: Effect.fail(
+ new DeviceHostUnavailableError({
+ hostId: LOCAL_DEVICE_HOST_ID,
+ reason: "Agent CLI installation is unavailable in this device service.",
+ }),
+ ),
+ agentTarget: (input) =>
+ Effect.gen(function* () {
+ const host = yield* resolveHost(input.hostId);
+ const ready = yield* agentReadinessIfSupported(input.hostId);
+ if (!ready)
+ return yield* new DeviceHostUnavailableError({
+ hostId: input.hostId,
+ reason:
+ "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.",
+ });
+ const configPath = yield* lifecycleLock.withPermit(
+ Effect.gen(function* () {
+ if (hosts.get(host.id) !== host)
+ return yield* new DeviceHostUnavailableError({
+ hostId: host.id,
+ reason: "Host configuration changed. Retry the operation.",
+ });
+ return yield* configureAgent(input.hostId, ready);
+ }),
+ );
+ return [
+ "--config",
+ configPath,
+ "--session",
+ agentDeviceSession(input.threadId, input.hostId, input.deviceId),
+ ];
+ }),
+ state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
+ subscribe: PubSub.subscribe(statePubSub),
+ configure,
+ list,
+ open,
+ close,
+ shutdown,
+ detail,
+ action,
+ screenshot,
+ readiness,
+ readinessIfSupported,
+ agentReadinessIfSupported,
+ currentReadiness,
+ sessionsForThread,
+ }),
+ setHostStatus,
+ withLifecycleLock: lifecycleLock.withPermit,
+ refreshHosts: Effect.gen(function* () {
+ const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
+ const unchanged = (id: DeviceHostId) =>
+ hosts.has(id) && hosts.get(id) === publishedHosts.get(id);
+ yield* publish((state) => ({
+ ...state,
+ hosts: summaries,
+ hostStatuses: Object.fromEntries(
+ Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)),
+ ),
+ devices: state.devices.filter((device) => unchanged(device.hostId)),
+ sessions: state.sessions.filter((session) => unchanged(session.hostId)),
+ }));
+ publishedHosts = new Map(hosts);
+ }),
+ };
});
/** @public Service construction is part of the canonical Effect module API. */
@@ -807,7 +869,12 @@ export const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
- const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => {
+ const settings = yield* ServerSettings.ServerSettingsService;
+ const scope = yield* Scope.Scope;
+ const hosts = new Map([
+ [localHost.id, localHost],
+ ]);
+ const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => {
const file = agentDeviceConfigPath(config.stateDir, hostId, path);
return writeAgentDeviceConfig(file, ready.agentDevice).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
@@ -822,7 +889,108 @@ export const make = Effect.gen(function* () {
),
Effect.as(file),
);
- });
+ };
+ const probeContext =
+ yield* Effect.context>>();
+ const service = yield* makeWithHosts(
+ hosts,
+ (host) =>
+ SshDeviceHost.probe(host).pipe(
+ Effect.provide(probeContext),
+ Effect.mapError(
+ (error) =>
+ new DeviceOperationError({
+ operation: "probe host",
+ reason: "request_failed",
+ cause: error,
+ }),
+ ),
+ ),
+ configureAgent,
+ );
+ const hostContext =
+ yield* Effect.context>>();
+ const configured = new Map();
+ const reconcile = (next: ReadonlyArray) =>
+ Effect.gen(function* () {
+ const removed = yield* service.withLifecycleLock(
+ Effect.gen(function* () {
+ const removed: Array<{ id: string; scope: Scope.Closeable }> = [];
+ for (const [id, previous] of configured) {
+ if (
+ next.some(
+ (host) =>
+ host.id === id &&
+ host.label === previous.config.label &&
+ host.target === previous.config.target &&
+ host.port === previous.config.port &&
+ host.identityFile === previous.config.identityFile,
+ )
+ )
+ continue;
+ hosts.delete(id);
+ configured.delete(id);
+ removed.push({ id, scope: previous.scope });
+ }
+ yield* service.refreshHosts;
+ return removed;
+ }),
+ );
+ // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts.
+ yield* Effect.forEach(
+ removed,
+ ({ id, scope }) =>
+ Effect.gen(function* () {
+ yield* Scope.close(scope, Exit.void);
+ yield* fs
+ .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true })
+ .pipe(Effect.ignore);
+ }),
+ { concurrency: 4, discard: true },
+ );
+ yield* service.withLifecycleLock(
+ Effect.gen(function* () {
+ for (const host of next) {
+ if (configured.has(host.id)) continue;
+ const hostScope = yield* Scope.fork(scope);
+ const instance = yield* SshDeviceHost.make(
+ host,
+ (ready) =>
+ configureAgent(host.id, ready).pipe(
+ Effect.asVoid,
+ Effect.mapError(
+ (error) =>
+ new DeviceHost.DeviceHostError({
+ hostId: host.id,
+ step: "configuring agent access",
+ cause: error,
+ }),
+ ),
+ ),
+ (status, detail) =>
+ service
+ .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) })
+ .pipe(Effect.asVoid),
+ ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext));
+ hosts.set(host.id, instance);
+ configured.set(host.id, { config: host, scope: hostScope });
+ }
+ yield* service.refreshHosts;
+ }),
+ );
+ });
+ const changes = yield* settings.subscribeChanges;
+ yield* reconcile((yield* settings.getSettings).deviceHosts);
+ yield* changes.pipe(
+ Stream.runForEach((value) => reconcile(value.deviceHosts)),
+ Effect.forkIn(scope),
+ );
+ yield* Effect.addFinalizer(() =>
+ Effect.forEach(configured.values(), (value) => Scope.close(value.scope, Exit.void), {
+ discard: true,
+ concurrency: 4,
+ }),
+ );
return {
...service,
agentCli: ensureAgentDevice(config.baseDir).pipe(
diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts
index e6b43cd81ee8..fa8d8cd11d17 100644
--- a/apps/server/src/device/DeviceToolchain.ts
+++ b/apps/server/src/device/DeviceToolchain.ts
@@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore";
import * as ProcessRunner from "../processRunner.ts";
const DEVICE_HUB_PACKAGE = "expo-device-hub";
-const DEVICE_HUB_VERSION = "0.9.0";
+export const DEVICE_HUB_VERSION = "0.9.0";
const AGENT_DEVICE_PACKAGE = "agent-device";
-const AGENT_DEVICE_VERSION = "0.20.10";
+export const AGENT_DEVICE_VERSION = "0.20.10";
const INSTALL_TIMEOUT = Duration.minutes(10);
const installLock = Semaphore.makeUnsafe(1);
diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts
index b24ebfbd98b6..5a18d6e826a6 100644
--- a/apps/server/src/device/LocalDeviceHost.ts
+++ b/apps/server/src/device/LocalDeviceHost.ts
@@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
+ nodePath: process.execPath,
run,
helpers: running.helpers,
});
diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts
new file mode 100644
index 000000000000..d968d3383fae
--- /dev/null
+++ b/apps/server/src/device/SshDeviceHost.test.ts
@@ -0,0 +1,131 @@
+// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout.
+import { expect, it } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Net from "@t3tools/shared/Net";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as PlatformError from "effect/PlatformError";
+import * as Sink from "effect/Sink";
+import * as Stream from "effect/Stream";
+import * as HttpClient from "effect/unstable/http/HttpClient";
+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import * as ServerConfig from "../config.ts";
+import * as DeviceHost from "./DeviceHost.ts";
+import * as SshDeviceHost from "./SshDeviceHost.ts";
+
+it.effect("preserves installed status after probes and cleans failed agent activation", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const home = yield* fs.makeTempDirectoryScoped();
+ const modes: string[] = [];
+ let forwards = 0;
+ let failForward = true;
+ let rejectConfig = true;
+ const spawner = ChildProcessSpawner.make((command) =>
+ Effect.gen(function* () {
+ if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
+ const forwarding = command.args.includes("-N");
+ let output = "";
+ if (forwarding) {
+ if (failForward) {
+ failForward = false;
+ return yield* PlatformError.systemError({
+ _tag: "AlreadyExists",
+ module: "ChildProcess",
+ method: "spawn",
+ description: "Port already bound",
+ });
+ }
+ forwards++;
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ forwards--;
+ }),
+ );
+ } else {
+ const stdin = command.options.stdin;
+ if (
+ !stdin ||
+ typeof stdin !== "object" ||
+ !("stream" in stdin) ||
+ !Stream.isStream(stdin.stream)
+ )
+ return yield* Effect.die("Missing script");
+ const script = yield* stdin.stream.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (a, b) => a + b,
+ ),
+ );
+ const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
+ modes.push(mode);
+ output = JSON.stringify({
+ nodePath: "/node",
+ platforms: [{ platform: "ios", available: true }],
+ hubPort: 1234,
+ helpers: { serveSimAxSettings: null, serveSimCli: null },
+ ...(mode === "agent-start"
+ ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" }
+ : {}),
+ });
+ }
+ return ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(123),
+ stdout: Stream.make(new TextEncoder().encode(output)),
+ stderr: Stream.empty,
+ all: Stream.empty,
+ exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)),
+ isRunning: Effect.succeed(forwarding),
+ kill: () => Effect.void,
+ stdin: Sink.drain,
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ unref: Effect.succeed(Effect.void),
+ });
+ }),
+ );
+ const host = yield* SshDeviceHost.make(
+ { id: "test", label: "Test", target: "test.example" },
+ () =>
+ rejectConfig
+ ? Effect.fail(
+ new DeviceHost.DeviceHostError({
+ hostId: "test",
+ step: "configuring agent access",
+ cause: new Error("fixture failure"),
+ }),
+ )
+ : Effect.void,
+ ).pipe(
+ Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make((request) =>
+ Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))),
+ ),
+ ),
+ );
+ yield* host.ensureReady(() => Effect.void);
+ expect(forwards).toBe(1);
+ expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
+ yield* host.platformAvailability("ios");
+ expect((yield* host.summary).hubInstalled).toBe(true);
+ const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result);
+ expect(failed._tag).toBe("Failure");
+ expect(forwards).toBe(0);
+ expect(modes.at(-1)).toBe("stop-agent");
+ expect(yield* host.current).toBeNull();
+ rejectConfig = false;
+ yield* host.ensureAgentReady(() => Effect.void);
+ yield* host.platformAvailability("ios");
+ expect((yield* host.summary).agentDeviceInstalled).toBe(true);
+ yield* host.stopAgent;
+ expect(forwards).toBe(1);
+ yield* host.stop;
+ expect(forwards).toBe(0);
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts
new file mode 100644
index 000000000000..4ccda0fdefe9
--- /dev/null
+++ b/apps/server/src/device/SshDeviceHost.ts
@@ -0,0 +1,434 @@
+import * as NodeCrypto from "node:crypto";
+import {
+ type DeviceHostSummary,
+ DevicePlatformAvailability,
+ type SshDeviceHostConfig,
+} from "@t3tools/contracts";
+import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
+import * as NetService from "@t3tools/shared/Net";
+import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
+import * as Exit from "effect/Exit";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Scope from "effect/Scope";
+import * as Semaphore from "effect/Semaphore";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as HttpClient from "effect/unstable/http/HttpClient";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+import * as ServerConfig from "../config.ts";
+import * as DeviceHost from "./DeviceHost.ts";
+import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
+
+const Probe = Schema.Struct({
+ nodePath: Schema.String,
+ platforms: Schema.Array(DevicePlatformAvailability),
+});
+const Started = Schema.Struct({
+ ...Probe.fields,
+ hubPort: Schema.Int,
+ daemonPort: Schema.optionalKey(Schema.Int),
+ token: Schema.optionalKey(Schema.String),
+ entryPath: Schema.optionalKey(Schema.String),
+ helpers: Schema.Struct({
+ serveSimAxSettings: Schema.NullOr(Schema.String),
+ serveSimCli: Schema.NullOr(Schema.String),
+ }),
+});
+const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe));
+const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started));
+const targetFor = (config: SshDeviceHostConfig) => ({
+ alias: config.target,
+ hostname: config.target,
+ username: null,
+ port: config.port ?? null,
+});
+const identityArgs = (config: SshDeviceHostConfig) =>
+ config.identityFile ? ["-i", config.identityFile] : [];
+const commandArgs = (script: string) => [
+ "sh",
+ "-c",
+ quoteRemoteArg(remoteDeviceEnvironment + script),
+];
+const bootstrap = (
+ config: SshDeviceHostConfig,
+ owner: string,
+ mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
+) =>
+ runSshCommand(targetFor(config), {
+ preHostArgs: identityArgs(config),
+ remoteCommandArgs: commandArgs(
+ 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node',
+ ),
+ stdin: remoteDeviceScript(owner, mode),
+ timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000,
+ }).pipe(
+ Effect.mapError(
+ (cause) => new DeviceHost.DeviceHostError({ hostId: config.id, step: mode, cause }),
+ ),
+ );
+
+export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) {
+ const result = yield* bootstrap(config, "probe", "probe");
+ const value = yield* decodeProbe(result.stdout.trim()).pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }),
+ ),
+ );
+ return {
+ id: config.id,
+ label: config.label,
+ kind: "ssh",
+ hubInstalled: false,
+ agentDeviceInstalled: false,
+ platforms: value.platforms,
+ } satisfies DeviceHostSummary;
+});
+
+export const make = Effect.fn("SshDeviceHost.make")(function* (
+ config: SshDeviceHostConfig,
+ onReady: (
+ ready: DeviceHost.DeviceHostAgentReady,
+ ) => Effect.Effect = () => Effect.void,
+ onStatus: (
+ status: "starting" | "ready" | "failed",
+ detail?: string,
+ ) => Effect.Effect = () => Effect.void,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const server = yield* ServerConfig.ServerConfig;
+ const net = yield* NetService.NetService;
+ const http = yield* HttpClient.HttpClient;
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const parentScope = yield* Scope.Scope;
+ const ssh = yield* resolveSshCommand;
+ const environmentId = yield* fs
+ .readFileString(server.environmentIdPath)
+ .pipe(Effect.orElseSucceed(() => server.stateDir));
+ const owner = NodeCrypto.createHash("sha256")
+ .update(`${environmentId}\0${server.stateDir}\0${config.id}`)
+ .digest("hex")
+ .slice(0, 24);
+ const provide = (
+ effect: Effect.Effect<
+ A,
+ E,
+ FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+ >,
+ ) =>
+ effect.pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+ const lock = yield* Semaphore.make(1);
+ let stopped = false;
+ let activated = false;
+ let wantsAgent = false;
+ let ready:
+ | (DeviceHost.DeviceHostReady & {
+ agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"];
+ })
+ | null = null;
+ let connectionScope: Scope.Closeable | null = null;
+ let summary: DeviceHostSummary = {
+ id: config.id,
+ label: config.label,
+ kind: "ssh",
+ hubInstalled: false,
+ agentDeviceInstalled: false,
+ platforms: [],
+ };
+
+ const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) =>
+ provide(
+ runSshCommand(targetFor(config), {
+ preHostArgs: identityArgs(config),
+ remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`),
+ ...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
+ ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
+ }),
+ ).pipe(
+ Effect.map((result) => ({ ...result, code: 0 })),
+ Effect.catch((error) =>
+ Effect.succeed({
+ stdout: "stdout" in error ? (error.stdout ?? "") : "",
+ stderr: error.message,
+ code: "exitCode" in error ? (error.exitCode ?? 127) : 127,
+ }),
+ ),
+ );
+
+ const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return<
+ DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] },
+ DeviceHost.DeviceHostError
+ > {
+ activated = true;
+ const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start"));
+ yield* onStatus("starting");
+ const remote = yield* decodeStarted(result.stdout.trim()).pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "reading host endpoints",
+ cause,
+ }),
+ ),
+ );
+ summary = {
+ ...summary,
+ platforms: remote.platforms,
+ hubInstalled: true,
+ agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled,
+ };
+ const hubPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "reserving hub port",
+ cause,
+ }),
+ ),
+ );
+ const daemonPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "reserving daemon port",
+ cause,
+ }),
+ ),
+ );
+ const scope = yield* Scope.make();
+ connectionScope = scope;
+ const child = yield* spawner
+ .spawn(
+ ChildProcess.make(
+ ssh,
+ [
+ ...baseSshArgs(targetFor(config), { batchMode: "yes" }),
+ ...identityArgs(config),
+ "-o",
+ "ExitOnForwardFailure=yes",
+ "-o",
+ "ServerAliveInterval=10",
+ "-o",
+ "ServerAliveCountMax=3",
+ "-N",
+ "-L",
+ `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`,
+ ...(remote.daemonPort === undefined
+ ? []
+ : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]),
+ config.target,
+ ],
+ { stdin: "ignore", stdout: "ignore", stderr: "pipe" },
+ ),
+ )
+ .pipe(
+ Effect.provideService(Scope.Scope, scope),
+ Effect.mapError(
+ (cause) =>
+ new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }),
+ ),
+ );
+ let stderr = "";
+ yield* child.stderr.pipe(
+ Stream.decodeText(),
+ Stream.runForEach((chunk) =>
+ Effect.sync(() => {
+ stderr = (stderr + chunk).slice(-2000);
+ }),
+ ),
+ Effect.forkIn(scope),
+ );
+ const next = {
+ nodePath: remote.nodePath,
+ hub: { origin: `http://127.0.0.1:${hubPort}` },
+ ...(remote.daemonPort !== undefined &&
+ remote.token !== undefined &&
+ remote.entryPath !== undefined
+ ? {
+ agentDevice: {
+ baseUrl: `http://127.0.0.1:${daemonPort}`,
+ token: remote.token,
+ entryPath: remote.entryPath,
+ },
+ }
+ : {}),
+ helpers: remote.helpers,
+ run,
+ };
+ for (const [baseUrl, route] of [
+ [next.hub.origin, "/readyz"],
+ ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []),
+ ]) {
+ yield* waitForHttpReady({
+ baseUrl: baseUrl!,
+ path: route!,
+ timeoutMs: 15000,
+ makeError: () =>
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "waiting for SSH forward",
+ cause: new Error(stderr || "Forwarded endpoint did not answer."),
+ }),
+ }).pipe(Effect.provideService(HttpClient.HttpClient, http));
+ }
+ if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice });
+ ready = next;
+ yield* onStatus("ready");
+ // Reconnect also repairs helpers that died while SSH itself stayed connected.
+ const unhealthy = Effect.gen(function* () {
+ while (true) {
+ yield* Effect.sleep("10 seconds");
+ const alive = yield* http.get(`${next.hub.origin}/readyz`).pipe(
+ Effect.timeout("5 seconds"),
+ Effect.map((r) => r.status === 200),
+ Effect.orElseSucceed(() => false),
+ );
+ const daemonAlive = next.agentDevice
+ ? yield* http.get(`${next.agentDevice!.baseUrl}/health`).pipe(
+ Effect.timeout("5 seconds"),
+ Effect.map((r) => r.status === 200),
+ Effect.orElseSucceed(() => false),
+ )
+ : true;
+ if (!alive || !daemonAlive) return;
+ }
+ });
+ yield* Effect.gen(function* () {
+ yield* Effect.raceFirst(child.exitCode.pipe(Effect.ignore), unhealthy);
+ if (stopped || connectionScope !== scope) return;
+ ready = null;
+ yield* onStatus("starting", "Reconnecting to device host…");
+ yield* Scope.close(scope, Exit.void);
+ let delay = 1000;
+ while (true) {
+ if (stopped || connectionScope !== scope) return;
+ yield* Effect.sleep(delay);
+ const result = yield* lock
+ .withPermit(
+ Effect.suspend(() => (stopped || ready ? Effect.void : connect().pipe(Effect.asVoid))),
+ )
+ .pipe(Effect.result);
+ if (result._tag === "Success") return;
+ yield* onStatus("failed", result.failure.message);
+ if (connectionScope && connectionScope !== scope)
+ yield* Scope.close(connectionScope, Exit.void);
+ connectionScope = scope;
+ delay = Math.min(delay * 2, 30000);
+ }
+ }).pipe(Effect.forkIn(parentScope));
+ return next;
+ });
+
+ const connect = Effect.fn("SshDeviceHost.connect")(function* () {
+ for (let attempt = 0; ; attempt++) {
+ const result = yield* connectOnce().pipe(Effect.result);
+ if (result._tag === "Success") return result.success;
+ const failedScope = connectionScope;
+ connectionScope = null;
+ if (failedScope) yield* Scope.close(failedScope, Exit.void);
+ // SSH binds after the reservation is released, so a competing bind needs fresh ports.
+ if (
+ attempt >= 2 ||
+ !["forwarding ports", "waiting for SSH forward"].includes(result.failure.step)
+ )
+ return yield* result.failure;
+ }
+ });
+
+ const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) =>
+ lock.withPermit(
+ Effect.gen(function* () {
+ stopped = false;
+ if (ready) return ready;
+ summary = yield* provide(probe(config));
+ yield* onPhase("installing");
+ return yield* connect().pipe(
+ Effect.tapError(() =>
+ connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void,
+ ),
+ );
+ }),
+ );
+ const stop = lock.withPermit(
+ Effect.gen(function* () {
+ stopped = true;
+ ready = null;
+ if (connectionScope) yield* Scope.close(connectionScope, Exit.void);
+ connectionScope = null;
+ if (activated) yield* provide(bootstrap(config, owner, "stop")).pipe(Effect.ignore);
+ activated = false;
+ wantsAgent = false;
+ }),
+ );
+ const changeAgent = (enabled: boolean) =>
+ lock.withPermit(
+ Effect.gen(function* () {
+ wantsAgent = enabled;
+ if (enabled && ready?.agentDevice) return { ...ready, agentDevice: ready.agentDevice };
+ if (!enabled && !ready?.agentDevice) return null;
+ ready = null;
+ const previousScope = connectionScope;
+ connectionScope = null;
+ if (previousScope) yield* Scope.close(previousScope, Exit.void);
+ if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent"));
+ return yield* connect().pipe(
+ Effect.onError(() =>
+ Effect.gen(function* () {
+ const failedScope = connectionScope;
+ connectionScope = null;
+ if (failedScope) yield* Scope.close(failedScope, Exit.void);
+ if (enabled)
+ yield* provide(bootstrap(config, owner, "stop-agent")).pipe(Effect.ignore);
+ }),
+ ),
+ );
+ }),
+ );
+ yield* Effect.addFinalizer(() => stop);
+ return {
+ id: config.id,
+ summary: Effect.sync(() => summary),
+ current: Effect.sync(() => ready),
+ ensureReady,
+ ensureAgentReady: (onPhase) =>
+ onPhase("installing").pipe(
+ Effect.flatMap(() => changeAgent(true)),
+ Effect.flatMap((value) =>
+ value?.agentDevice
+ ? Effect.succeed({ ...value, agentDevice: value.agentDevice })
+ : Effect.fail(
+ new DeviceHost.DeviceHostError({
+ hostId: config.id,
+ step: "starting agent tools",
+ cause: new Error("Daemon endpoint missing"),
+ }),
+ ),
+ ),
+ ),
+ stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore),
+ stop,
+ platformAvailability: (platform) =>
+ provide(probe(config)).pipe(
+ Effect.map((value) => {
+ summary = { ...summary, platforms: value.platforms };
+ return value.platforms.find((p) => p.platform === platform)!;
+ }),
+ Effect.orElseSucceed(() => ({
+ platform,
+ available: false,
+ reason: "Cannot reach device host. Test its SSH connection in Settings.",
+ })),
+ ),
+ } satisfies DeviceHost.DeviceHost["Service"];
+});
diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts
new file mode 100644
index 000000000000..eadd85fd047d
--- /dev/null
+++ b/apps/server/src/device/sshDeviceScript.test.ts
@@ -0,0 +1,220 @@
+// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes.
+import * as Effect from "effect/Effect";
+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { describe, expect, it } from "@effect/vitest";
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFSP from "node:fs/promises";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeUtil from "node:util";
+import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
+import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
+
+const exec = NodeUtil.promisify(NodeChildProcess.execFile);
+
+it.effect("finds Android Studio Java for a non-interactive SSH session", () =>
+ Effect.gen(function* () {
+ if ((yield* HostProcessPlatform) === "win32") return;
+ yield* Effect.promise(async () => {
+ const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-"));
+ try {
+ const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr");
+ await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true });
+ await NodeFSP.writeFile(
+ NodePath.join(javaHome, "bin/java"),
+ "#!/bin/sh\necho test-java\n",
+ { mode: 0o755 },
+ );
+ const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], {
+ env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" },
+ });
+ expect(result.stdout.trim()).toBe("test-java");
+ } finally {
+ await NodeFSP.rm(home, { recursive: true, force: true });
+ }
+ });
+ }),
+);
+
+it.effect("preserves shell metacharacters and newlines in remote arguments", () =>
+ Effect.gen(function* () {
+ if ((yield* HostProcessPlatform) === "win32") return;
+ yield* Effect.promise(async () => {
+ const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line";
+ const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]);
+ expect(result.stdout).toBe(value);
+ });
+ }),
+);
+
+describe("remote helper lifecycle", () => {
+ it.effect("reuses its own healthy helpers and stops only its own runtime", () =>
+ Effect.gen(function* () {
+ if ((yield* HostProcessPlatform) === "win32") return;
+ yield* Effect.promise(async () => {
+ const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-"));
+ const bin = NodePath.join(home, "bin");
+ await NodeFSP.mkdir(bin);
+ await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
+ const root = NodePath.join(home, ".t3/device");
+ const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`);
+ const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`);
+ const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs");
+ const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs");
+ await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), {
+ recursive: true,
+ });
+ await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), {
+ recursive: true,
+ });
+ await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION);
+ await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION);
+ await NodeFSP.writeFile(
+ hub,
+ `import http from 'node:http'; import fs from 'node:fs';
+if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);}
+const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`,
+ );
+ await NodeFSP.writeFile(
+ agent,
+ `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process';
+const args=process.argv.slice(2);
+const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1];
+const file=path.join(state,'daemon.json');
+if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} }
+else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); }
+else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); }
+`,
+ );
+ const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
+ const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
+ let invocation = 0;
+ const invoke = async (
+ owner: string,
+ mode: "start" | "agent-start" | "stop-agent" | "stop",
+ upgraded = false,
+ ) => {
+ const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`);
+ await NodeFSP.writeFile(
+ file,
+ `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` +
+ remoteDeviceScript(owner, mode)
+ .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION)
+ .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION),
+ );
+ const result = await exec(process.execPath, [file], {
+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
+ });
+ return result.stdout ? JSON.parse(result.stdout) : null;
+ };
+ const template = NodePath.join(home, "hub-template");
+ await NodeFSP.cp(hubDir, template, { recursive: true });
+ await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
+ const installLock = hubDir + ".lock";
+ await NodeFSP.symlink("2147483647:exited-installer", installLock);
+ await NodeFSP.writeFile(
+ NodePath.join(bin, "npm"),
+ `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`,
+ { mode: 0o755 },
+ );
+ await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true });
+ await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), "");
+ try {
+ const [manual, concurrent] = await Promise.all([
+ invoke("one", "start"),
+ invoke("one", "start"),
+ ]);
+ expect(concurrent.hubPort).toBe(manual.hubPort);
+ expect(manual.daemonPort).toBeUndefined();
+ await expect(
+ NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")),
+ ).rejects.toThrow();
+ const [first, concurrentAgent] = await Promise.all([
+ invoke("one", "agent-start"),
+ invoke("one", "agent-start"),
+ ]);
+ expect(concurrentAgent.hubPort).toBe(first.hubPort);
+ expect(concurrentAgent.daemonPort).toBe(first.daemonPort);
+ const second = await invoke("two", "agent-start");
+ const reused = await invoke("one", "agent-start");
+ expect(reused.hubPort).toBe(first.hubPort);
+ expect(reused.daemonPort).toBe(first.daemonPort);
+ expect(second.hubPort).not.toBe(first.hubPort);
+ expect(second.daemonPort).not.toBe(first.daemonPort);
+ const firstHub = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
+ );
+ const secondHub = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"),
+ );
+ await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), "");
+ let repaired = await invoke("one", "agent-start");
+ expect(repaired.hubPort).not.toBe(first.hubPort);
+ const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8"))
+ .trim()
+ .split("\n");
+ expect(stopped).toContain(String(firstHub.pid));
+ expect(stopped).not.toContain(String(secondHub.pid));
+ const previousDaemon = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
+ );
+ for (const [source, name, version] of [
+ [hubDir, "expo-device-hub", nextHubVersion],
+ [agentDir, "agent-device", nextAgentVersion],
+ ]) {
+ const destination = NodePath.join(root, `tools/${name}@${version}`);
+ await NodeFSP.cp(source!, destination, { recursive: true });
+ await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!);
+ }
+ const upgraded = await invoke("one", "agent-start", true);
+ expect(upgraded.entryPath).toContain(nextAgentVersion);
+ const upgradedHub = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
+ );
+ expect(upgradedHub.entryPath).toContain(nextHubVersion);
+ const upgradedDaemon = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
+ );
+ expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid);
+ expect(await invoke("one", "agent-start", true)).toEqual(upgraded);
+ await NodeFSP.writeFile(
+ NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`),
+ "",
+ );
+ repaired = await invoke("one", "agent-start", true);
+ expect(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
+ ).toBe(String(upgradedDaemon.pid));
+ expect(repaired.daemonPort).not.toBe(upgraded.daemonPort);
+ // Stop still uses the recorded entry when a future pinned package is not installed yet.
+ const originalScript = remoteDeviceScript("one", "stop-agent");
+ const upgradedStop = NodePath.join(home, "upgraded-stop.cjs");
+ await NodeFSP.writeFile(
+ upgradedStop,
+ originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"),
+ );
+ await exec(process.execPath, [upgradedStop], {
+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
+ });
+ const daemon = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
+ );
+ expect(
+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
+ ).toBe(String(daemon.pid));
+ expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true);
+ await invoke("one", "stop");
+ expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true);
+ expect(
+ JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"))
+ .owner,
+ ).toBe("two");
+ } finally {
+ await invoke("one", "stop").catch(() => {});
+ await invoke("two", "stop").catch(() => {});
+ await NodeFSP.rm(home, { recursive: true, force: true });
+ }
+ });
+ }),
+ );
+});
diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts
new file mode 100644
index 000000000000..bbdb828c1a74
--- /dev/null
+++ b/apps/server/src/device/sshDeviceScript.ts
@@ -0,0 +1,192 @@
+import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
+
+export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
+
+/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */
+export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
+if [ -z "$ANDROID_HOME" ]; then
+ if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk";
+ elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi
+fi
+if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi
+if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then
+ for device_java_home in "$HOME/.local/opt/android-studio/jbr" /opt/android-studio/jbr /Applications/Android\\ Studio.app/Contents/jbr "$HOME/Applications/Android Studio.app/Contents/jbr"; do
+ if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi
+ done
+fi
+if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi
+`;
+
+/** Node runs this on the host. All paths it returns belong to that host. */
+export const remoteDeviceScript = (
+ owner: string,
+ mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
+) =>
+ `
+const owner = ${JSON.stringify(owner)};
+const mode = ${JSON.stringify(mode)};
+const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)};
+const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)};
+` +
+ String.raw`
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const net = require('node:net');
+const { spawn, spawnSync } = require('node:child_process');
+const root = path.join(os.homedir(), '.t3', 'device');
+const state = path.join(root, 'hosts', owner);
+const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });
+const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
+const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); };
+const stopHub = hub => {
+ if (!hub || hub.owner !== owner) return;
+ const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || '';
+ if (command.includes(hub.entryPath) && command.includes(String(hub.port))) {
+ try { process.kill(hub.pid, 'SIGTERM'); } catch {}
+ }
+};
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } };
+const port = () => new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(() => resolve(value)); }); });
+async function acquireLock(lock, complete = () => false) {
+ const deadline = Date.now() + 600000;
+ const token = process.pid + ':' + require('node:crypto').randomUUID();
+ const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
+ while (true) {
+ try {
+ // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner.
+ fs.symlinkSync(token, lock);
+ return () => { if (owner() === token) fs.unlinkSync(lock); };
+ } catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+ if (complete()) return null;
+ const previous = owner();
+ if (previous === null) continue;
+ const pid = Number(previous.split(':')[0]);
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock);
+ try { process.kill(pid, 0); } catch (error) {
+ if (error.code === 'ESRCH' && owner() === previous) {
+ try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
+ continue;
+ }
+ }
+ if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.');
+ await sleep(500);
+ }
+ }
+}
+async function install(name, version, entry) {
+ const dir = path.join(root, 'tools', name + '@' + version);
+ const file = path.join(dir, 'node_modules', name, entry);
+ const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version;
+ if (complete()) return file;
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
+ const lock = dir + '.lock';
+ const release = await acquireLock(lock, complete);
+ if (!release) return file;
+ let staging;
+ try {
+ if (complete()) return file;
+ staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-'));
+ const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 });
+ if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000)));
+ if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name);
+ fs.writeFileSync(path.join(staging, '.install-complete'), version);
+ fs.rmSync(dir, { recursive: true, force: true });
+ fs.renameSync(staging, dir);
+ return file;
+ } finally {
+ if (staging) fs.rmSync(staging, { recursive: true, force: true });
+ release();
+ }
+}
+(async () => {
+ const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0;
+ const android = run('adb', ['version']).status === 0;
+ const platforms = [
+ { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) },
+ { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) },
+ ];
+ if (mode === 'probe') {
+ if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.');
+ if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.');
+ console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return;
+ }
+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
+ // Serialize starts and stops for this environment/host owner, including agent startup.
+ const hostLock = path.join(state, 'runtime.lock');
+ const releaseHost = await acquireLock(hostLock);
+ try {
+ const hubFile = path.join(state, 'hub.json');
+ const daemonFile = path.join(state, 'daemon.json');
+ const agentFile = path.join(state, 'agent.json');
+ if (mode === 'stop' || mode === 'stop-agent') {
+ const hub = read(hubFile);
+ if (mode === 'stop' && hub && hub.owner === owner) {
+ stopHub(hub);
+ fs.rmSync(hubFile, { force: true });
+ }
+ const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs');
+ if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]);
+ return;
+ }
+ if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' '));
+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
+ const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs');
+ let hub = read(hubFile);
+ if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || !await healthy(hub.port, '/readyz')) {
+ stopHub(hub);
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const hubPort = await port();
+ const log = fs.openSync(path.join(state, 'hub.log'), 'a');
+ const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device'], {
+ cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
+ });
+ try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); }
+ finally { fs.closeSync(log); }
+ child.unref();
+ hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry };
+ write(hubFile, hub);
+ const deadline = Date.now() + 30000;
+ let listening = false;
+ while (child.exitCode === null && child.signalCode === null) {
+ if (await healthy(hub.port, '/readyz')) { listening = true; break; }
+ if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); }
+ await sleep(200);
+ }
+ if (listening) break;
+ // Port reservation and binding happen in different processes. Retry an early exit with a fresh port.
+ fs.rmSync(hubFile, { force: true });
+ if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log'));
+ }
+ }
+ let agentResult = {};
+ if (mode === 'agent-start') {
+ const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs');
+ const previousAgent = read(agentFile)?.entryPath;
+ let daemon = read(daemonFile);
+ if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) {
+ const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]);
+ if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.');
+ fs.rmSync(daemonFile, { force: true });
+ daemon = null;
+ }
+ if (!daemon) {
+ fs.rmSync(daemonFile, { force: true });
+ const env = { ...process.env, AGENT_DEVICE_STATE_DIR: state, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1' };
+ delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG;
+ run(process.execPath, [agentEntry, 'devices', '--json'], { env });
+ daemon = read(daemonFile);
+ }
+ if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state);
+ write(agentFile, { entryPath: agentEntry });
+ agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry };
+ }
+ const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist');
+ const optional = file => fs.existsSync(file) ? file : null;
+ console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult,
+ helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } }));
+ } finally { releaseHost(); }
+})().catch(error => { console.error(error.message); process.exitCode = 1; });
+`;
diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts
index 24097a92faac..3a9307314c39 100644
--- a/apps/server/src/mcp/McpDeviceToolkit.test.ts
+++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts
@@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
screenshot: () => Effect.succeed({ device, png }),
close: () => Effect.void,
agentCli: Effect.succeed("/cli"),
+ testHost: () => Effect.die("not used"),
agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),
});
diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts
index 3bc89d22bc60..feb69ec630d6 100644
--- a/apps/server/src/mcp/toolkits/device/handlers.ts
+++ b/apps/server/src/mcp/toolkits/device/handlers.ts
@@ -134,6 +134,9 @@ const handlers = {
.filter((session) => session.threadId === scope.threadId)
.map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));
return {
+ hostStatuses: Object.fromEntries(
+ Object.entries(state.hostStatuses).filter(([id]) => !hostId || id === hostId),
+ ),
hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts,
devices: hostId
? state.devices.filter((device) => device.hostId === hostId)
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 71a8ed19d905..f59a753d9c72 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -2770,6 +2770,10 @@ const makeWsRpcLayer = (
observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), {
"rpc.aggregate": "device",
}),
+ [WS_METHODS.deviceTestHost]: (input) =>
+ observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), {
+ "rpc.aggregate": "device",
+ }),
[WS_METHODS.deviceList]: (_input) =>
observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {
"rpc.aggregate": "device",
diff --git a/apps/web/src/components/device/DeviceHostAvailability.tsx b/apps/web/src/components/device/DeviceHostAvailability.tsx
new file mode 100644
index 000000000000..03a0b175dae8
--- /dev/null
+++ b/apps/web/src/components/device/DeviceHostAvailability.tsx
@@ -0,0 +1,27 @@
+import type { DevicePlatformAvailability } from "@t3tools/contracts";
+import { Check, Minus } from "lucide-react";
+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
+
+export function DeviceHostAvailability({
+ platforms,
+}: {
+ platforms: ReadonlyArray;
+}) {
+ return (
+
+ {platforms.map((platform) => (
+
+ }>
+ {platform.available ? : }
+ {platform.platform === "ios" ? "iOS" : "Android"}{" "}
+ {platform.available ? "available" : "unavailable"}
+
+
+ {platform.reason ??
+ (platform.platform === "ios" ? "iOS available" : "Android available")}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx
new file mode 100644
index 000000000000..e61cf742dfd5
--- /dev/null
+++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx
@@ -0,0 +1,334 @@
+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
+import { AppleIcon, AndroidIcon } from "../Icons";
+import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
+import { Spinner } from "../ui/spinner";
+import type {
+ DevicePlatformAvailability,
+ EnvironmentId,
+ SshDeviceHostConfig,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import { randomUUID } from "../../lib/utils";
+import { useState } from "react";
+import { deviceEnvironment, useDeviceState } from "../../state/device";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import { MoreVertical, PlusIcon } from "lucide-react";
+import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
+import { SettingsRow } from "./settingsLayout";
+
+/** Host names and identity paths belong to the selected environment, never all environments. */
+export function DeviceHostsSettings(props: {
+ environmentId: EnvironmentId | null;
+ hosts: ReadonlyArray;
+}) {
+ const update = useAtomCommand(serverEnvironment.updateSettings);
+ const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
+ const { state } = useDeviceState(props.environmentId);
+ const [editing, setEditing] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const validPort = (port: number | undefined) =>
+ port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);
+ const [checks, setChecks] = useState<
+ Record<
+ string,
+ { pending?: boolean; platforms?: ReadonlyArray; error?: string }
+ >
+ >({});
+ const setCheck = (id: string, value: (typeof checks)[string]) =>
+ setChecks((current) => ({ ...current, [id]: value }));
+ const save = async (hosts: ReadonlyArray) => {
+ if (!props.environmentId) return;
+ setBusy(true);
+ try {
+ const saved = await update({
+ environmentId: props.environmentId,
+ input: { patch: { deviceHosts: hosts } },
+ });
+ if (saved._tag === "Success") {
+ setEditing(null);
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+ const testConnection = async (host: SshDeviceHostConfig) => {
+ if (!props.environmentId || checks[host.id]?.pending) return;
+ setCheck(host.id, { pending: true });
+ try {
+ const summary = await test({ environmentId: props.environmentId, input: host });
+ setCheck(
+ host.id,
+ summary._tag === "Failure"
+ ? { error: Cause.pretty(summary.cause) }
+ : { platforms: summary.value.platforms },
+ );
+ } catch (error) {
+ setCheck(host.id, { error: error instanceof Error ? error.message : String(error) });
+ }
+ };
+ return (
+ {
+ setEditing({ id: randomUUID(), label: "", target: "" });
+ }}
+ >
+ Add host
+
+ }
+ >
+
+ {!props.environmentId ? (
+
+ Select one connected environment to manage its device hosts.
+
+ ) : (
+ <>
+ {props.hosts.map((host) => {
+ const status = state.hostStatuses[host.id];
+ const check = checks[host.id];
+ const platforms =
+ check?.platforms ??
+ state.hosts.find((value) => value.id === host.id)?.platforms ??
+ [];
+ const progress = check?.pending
+ ? "Checking connection…"
+ : status?.status === "installing"
+ ? "Installing device support…"
+ : status?.status === "starting"
+ ? "Connecting…"
+ : null;
+ const error =
+ check?.error ?? (status?.status === "failed" ? status.detail : undefined);
+ return (
+
+
+
+
{host.label}
+ {platforms
+ .filter((platform) => platform.available)
+ .map((platform) => (
+
+
+ }
+ >
+ {platform.platform === "ios" ? (
+
+ ) : (
+
+ )}
+
+
+ {platform.platform === "ios" ? "iOS available" : "Android available"}
+
+
+ ))}
+
+
{host.target}
+ {error ? (
+
+
+ Connection failed
+ {error}
+
+
+ ) : null}
+
+ {progress ? (
+
+
+ {progress}
+
+ ) : null}
+
+
+ }
+ >
+
+
+
+ {
+ setEditing(host);
+ }}
+ >
+ Edit
+
+
+ void save(props.hosts.filter((value) => value.id !== host.id))
+ }
+ >
+ Remove
+
+
+
+
void testConnection(host)}
+ >
+ Test connection
+
+
+ );
+ })}
+ {editing ? (
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx
index a3f76f49cd5f..8cb21532a426 100644
--- a/apps/web/src/components/settings/IntegrationsSettings.tsx
+++ b/apps/web/src/components/settings/IntegrationsSettings.tsx
@@ -1,3 +1,4 @@
+import { DeviceHostsSettings } from "./DeviceHostsSettings";
/**
* Integrations settings - preferences for surfaces T3 Code embeds rather than
* owns. Browser is the first section: the defaults a preview tab opens at,
@@ -12,6 +13,7 @@ import {
type BrowserLinkTarget,
type BrowserProfile,
type EnvironmentId,
+ type SshDeviceHostConfig,
BROWSER_PROFILE_NAME_MAX_LENGTH,
BROWSER_RECORDING_FRAME_RATES,
DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
@@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() {
function DeviceIntegrationSettings() {
const primaryEnvironment = usePrimaryEnvironment();
- const environmentId = primaryEnvironment?.environmentId ?? null;
+ const { environments } = useEnvironments();
+ const [selectedId, setSelectedId] = useState(null);
+ const selected =
+ environments.find((environment) => environment.environmentId === selectedId) ??
+ environments.find(
+ (environment) => environment.environmentId === primaryEnvironment?.environmentId,
+ ) ??
+ environments[0];
+ const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
+ const environmentId = connected ? selected.environmentId : null;
+
+ return (
+
+ {environments.length > 1 ? (
+ setSelectedId(value)}
+ >
+
+ {selected?.label ?? "Select environment"}
+
+
+ {environments.map((environment) => (
+
+ {environment.label}
+ {environment.connection.phase === "connected" ? "" : " · Offline"}
+
+ ))}
+
+
+ }
+ />
+ ) : null}
+
+
+ );
+}
+
+function DeviceIntegrationControls({
+ environmentId,
+ hosts,
+ enabled,
+ agentAccessEnabled,
+}: {
+ environmentId: EnvironmentId | null;
+ hosts: ReadonlyArray;
+ enabled: boolean;
+ agentAccessEnabled: boolean;
+}) {
const { state, loaded } = useDeviceState(environmentId);
const configure = useAtomCommand(deviceEnvironment.configure);
const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
- const enabled = state.hostStatus !== "disabled";
const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
const [platformsRevealed, setPlatformsRevealed] = useState(false);
// Keep diagnostics visible through subsequent agent setup and refresh phases.
if (platformsRevealed && !enabled) setPlatformsRevealed(false);
- if (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
+ if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
setPlatformsRevealed(true);
}
@@ -615,7 +674,7 @@ function DeviceIntegrationSettings() {
};
return (
-
+ <>
{pending === "agent" ? : null}
@@ -689,7 +748,8 @@ function DeviceIntegrationSettings() {
{state.hostStatusDetail}
) : null}
-
+
+ >
);
}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 24892b0e1e3b..f56579990714 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -402,6 +402,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/projects",
searchTerms: ["allow open drive preview tools sessions"],
},
+ {
+ id: "device-hosts",
+ title: "Device hosts",
+ to: "/settings/integrations",
+ searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"],
+ },
{
id: "agent-device-access",
title: "Agent device access",
diff --git a/docs/internals/devices.md b/docs/internals/devices.md
index 49027022fdbc..9b34851439ca 100644
--- a/docs/internals/devices.md
+++ b/docs/internals/devices.md
@@ -3,8 +3,7 @@
The environment server owns simulators and emulators the way it owns
terminals: discovery, streaming, and agent access all run there, and every
client reaches them through the environment connection. This is what makes the
-Device panel work over Tailscale and T3 Connect, and what will let a device
-host on another machine slot in later.
+Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices.
## Two external tools, one seam
@@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down.
Everything platform-specific sits behind
[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the
proxy, and the MCP tools only see a hub origin and an agent-device endpoint.
-An SSH or cloud host would forward those two things to the server and change
-nothing above it.
+SSH hosts forward both endpoints to server loopback. Every proxied request
+also carries the host id; device ids alone are not unique across hosts.
## The hub is never exposed
@@ -57,9 +56,8 @@ screenshot capture and stream tuning.
The `device_*` toolkit is deliberately four tools: list, open, screenshot, and
close. Driving happens through the `agent-device` CLI, which has the semantic
snapshot model agents need and stays current with its own releases. T3 prepends
-a shim directory to the provider's PATH and sets
-`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the
-agent never handles the endpoint or token.
+a shim directory to the provider's PATH. The CLI installs on the environment
+server even when that server cannot run simulators. Hosts start on demand.
That environment is fixed when the provider subprocess spawns, so
[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts)
diff --git a/docs/user/devices.md b/docs/user/devices.md
index 51a0a578b607..7d1b91792ae3 100644
--- a/docs/user/devices.md
+++ b/docs/user/devices.md
@@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it.
Choose a running device to watch it, or choose **Start** next to a stopped
device to boot it. The panel shows when you or an agent starts a device.
+Each device opens in its own tab. Use **+ → Device** to open another, and
+double-click a tab name or choose **Rename** from its context menu to rename it.
+Only the visible tab streams video; switching tabs keeps both devices running.
Turn off the device hub in **Settings → Integrations → Devices** to stop the
helper processes; simulators and emulators keep running until you power them
off.
@@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices.
The screen is interactive: click and drag to touch, type while the screen is
focused, and use the toolbar for Home, Back, and Recents on Android, rotate on
iOS, and power off. Close the tab to stop watching; the device keeps running
-unless you power it off.
+unless you power it off. Closed tabs stay closed after a reload. To watch the
+device again, choose it from **+ → Device**.
## Tools
@@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the
local network, Tailscale, and T3 Connect. Live video needs a secure page
(HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower
still-image stream and Android cannot show video.
+
+## SSH device hosts
+
+In Settings → Integrations → Devices, select one connected environment
+and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with
+an optional identity file and port. These resolve on the environment server,
+so use the SSH configuration and keys available there. Password prompts are
+not supported.
+
+**Test connection** checks SSH, Node, npm, and platform tools without installing
+anything. The first device listing installs pinned device tools on the host.
+Node 22 or newer and npm must be available to non-interactive SSH commands.
+T3 checks common Homebrew and Android SDK locations; custom installations need
+the appropriate PATH and ANDROID_HOME on the host.
+
+The picker identifies devices by host when several hosts are configured.
+Connections recover after interruptions. Removing a host closes its device
+sessions and stops its T3 helpers when reachable; simulators keep running.
+
+T3 provides discovery, streaming, and control. Arrange app builds,
+installation, and connectivity to development servers such as Metro separately.
+A simulator on another machine cannot reach Metro through your environment's
+localhost without forwarding or another reachable address.
diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts
index df27f793720c..1e6523497ef3 100644
--- a/packages/client-runtime/src/state/device.ts
+++ b/packages/client-runtime/src/state/device.ts
@@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms(
scheduler,
concurrency,
}),
+ testHost: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:device:test-host",
+ tag: WS_METHODS.deviceTestHost,
+ }),
list: createEnvironmentRpcCommand(runtime, {
label: "environment-data:device:list",
tag: WS_METHODS.deviceList,
diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts
index 895a954e2e40..03102dd62b44 100644
--- a/packages/contracts/src/device.ts
+++ b/packages/contracts/src/device.ts
@@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type;
/** The server machine. Always present; other host kinds are future work. */
export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;
+/** SSH aliases and key paths are resolved on the environment server. */
+export const SshDeviceHostConfig = Schema.Struct({
+ id: DeviceHostId.check(
+ Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/),
+ Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."),
+ ),
+ label: TrimmedNonEmptyString,
+ target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)),
+ identityFile: Schema.optional(TrimmedNonEmptyString),
+ port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
+});
+export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type;
+
+export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(
+ Schema.makeFilter(
+ (hosts) =>
+ new Set(hosts.map((host) => host.id)).size === hosts.length ||
+ "Device host ids must be unique.",
+ ),
+);
+
/** Simulator udid or adb serial (an AVD name while it is not running). */
export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
export type DeviceId = typeof DeviceId.Type;
@@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
export const DeviceHostSummary = Schema.Struct({
id: DeviceHostId,
- kind: Schema.Literals(["local"]),
+ kind: Schema.Literals(["local", "ssh"]),
label: TrimmedNonEmptyString,
platforms: Schema.Array(DevicePlatformAvailability),
hubInstalled: Schema.Boolean,
@@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type;
// panel describe devices the same way.
export const DeviceToolListResult = Schema.Struct({
+ hostStatuses: DeviceServiceState.fields.hostStatuses,
hosts: Schema.Array(DeviceHostSummary),
devices: Schema.Array(DeviceSummary),
/** Devices already open in this thread's Device panel. */
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index a5d35bf2361d..5e11aeb28fa3 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -189,6 +189,8 @@ import {
DeviceDetailInput,
DeviceError,
DeviceListInput,
+ SshDeviceHostConfig,
+ DeviceHostSummary,
DeviceOpenInput,
DeviceServiceState,
DeviceSession,
@@ -328,6 +330,7 @@ export const WS_METHODS = {
// Device methods
deviceConfigure: "device.configure",
deviceList: "device.list",
+ deviceTestHost: "device.testHost",
deviceOpen: "device.open",
deviceClose: "device.close",
deviceShutdown: "device.shutdown",
@@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov
stream: true,
});
+const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, {
+ payload: SshDeviceHostConfig,
+ success: DeviceHostSummary,
+ error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
+});
+
const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, {
payload: DeviceListInput,
success: DeviceServiceState,
@@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make(
WsSubscribeDiscoveredLocalServersRpc,
WsDeviceConfigureRpc,
WsDeviceListRpc,
+ WsDeviceTestHostRpc,
WsDeviceOpenRpc,
WsDeviceCloseRpc,
WsDeviceShutdownRpc,
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 8cbf0e55b9df..7322307c1f89 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => {
expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux");
});
});
+
+const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings);
+
+it("validates remote device hosts and rejects ambiguous host ids", () => {
+ const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 };
+ expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]);
+ expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow();
+ expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow();
+ expect(() =>
+ decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }),
+ ).toThrow();
+ expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow();
+});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 96516367612c..dd6136461fc1 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1,3 +1,4 @@
+import { SshDeviceHostConfigs } from "./device.ts";
import * as Effect from "effect/Effect";
import * as Duration from "effect/Duration";
import * as Schema from "effect/Schema";
@@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({
enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
/** Whether the server-local Device panel setup flow has been completed. */
deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
),
@@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({
enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
+ deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index a5e428fcdaac..cc783fe64bf3 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -21,6 +21,16 @@ import {
} from "./serverSettings.ts";
describe("serverSettings helpers", () => {
+ it("replaces SSH host lists when saving, editing, and removing hosts", () => {
+ const host = { id: "mini", label: "Mac mini", target: "mini" };
+ const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });
+ expect(saved.deviceHosts).toEqual([host]);
+ const replacement = { ...host, target: "other-mini" };
+ const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] });
+ expect(edited.deviceHosts).toEqual([replacement]);
+ expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]);
+ });
+
it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
const project = { id: ProjectId.make("project-actions"), scripts: [] };
const action = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c58c455b465..bebd36cec2e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -540,6 +540,9 @@ importers:
'@t3tools/shared':
specifier: workspace:*
version: link:../../packages/shared
+ '@t3tools/ssh':
+ specifier: workspace:*
+ version: link:../../packages/ssh
'@t3tools/tailscale':
specifier: workspace:*
version: link:../../packages/tailscale