From 9cce787fa90833c8b1948bc39dca6d5c91c30690 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 17:27:29 -0700
Subject: [PATCH 1/8] feat(devices): report installed and required tool
versions per host
---
.../devices/DevicePreviewRouteScreen.tsx | 13 +++++++
.../server/src/device/DeviceToolchain.test.ts | 38 ++++++++++++++++++-
apps/server/src/device/DeviceToolchain.ts | 28 ++++++++++++++
apps/server/src/device/LocalDeviceHost.ts | 15 ++++++++
apps/server/src/device/SshDeviceHost.ts | 10 ++++-
.../server/src/device/sshDeviceScript.test.ts | 7 +++-
apps/server/src/device/sshDeviceScript.ts | 25 +++++++++++-
.../components/device/DeviceToolVersions.tsx | 12 ++++++
.../settings/DeviceHostsSettings.tsx | 7 ++++
.../settings/IntegrationsSettings.tsx | 8 ++++
.../settings/deviceHostConnectionChecks.ts | 12 +++++-
packages/client-runtime/src/state/device.ts | 16 +++++++-
packages/contracts/src/device.ts | 14 +++++++
13 files changed, 196 insertions(+), 9 deletions(-)
create mode 100644 apps/web/src/components/device/DeviceToolVersions.tsx
diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
index 824164dc2e2b..628b30cd6032 100644
--- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
+++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
@@ -1,3 +1,4 @@
+import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
@@ -116,6 +117,18 @@ function DevicePreviewScreen({
};
const controls: ScreenHeaderMenuItem[] = [
+ {
+ id: "device-tools",
+ title: "Device tool versions",
+ icon: "info.circle",
+ onPress: () =>
+ Alert.alert(
+ "Device tool versions",
+ deviceToolVersionLabels(
+ state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
+ ).join("\n"),
+ ),
+ },
{
id: "reload",
title: "Reload stream",
diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts
index 9f70ca91a8e1..8b8babed3269 100644
--- a/apps/server/src/device/DeviceToolchain.test.ts
+++ b/apps/server/src/device/DeviceToolchain.test.ts
@@ -6,7 +6,12 @@ import * as Path from "effect/Path";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import * as ProcessRunner from "../processRunner.ts";
-import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts";
+import {
+ deviceToolVersions,
+ DEVICE_HUB_VERSION,
+ ensureDeviceHub,
+ isDeviceHubInstalled,
+} from "./DeviceToolchain.ts";
it.effect("failed installation cleans staging and exposes only a safe failure message", () =>
Effect.gen(function* () {
@@ -37,3 +42,34 @@ it.effect("failed installation cleans staging and exposes only a safe failure me
expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
+
+it.effect("inventory reports only completed versions without installing the required version", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const base = yield* fs.makeTempDirectoryScoped();
+ for (const [version, sentinel] of [
+ ["0.9.0", "0.9.0"],
+ [DEVICE_HUB_VERSION, "wrong"],
+ [".staging-123", ".staging-123"],
+ ]) {
+ const dir = path.join(base, "tools", "expo-device-hub", version!);
+ yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), {
+ recursive: true,
+ });
+ yield* fs.writeFileString(
+ path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"),
+ "",
+ );
+ yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!);
+ }
+ const tools = yield* deviceToolVersions(base);
+ expect(tools.hub).toEqual({
+ requiredVersion: DEVICE_HUB_VERSION,
+ installedVersions: ["0.9.0"],
+ runningVersion: null,
+ });
+ expect(tools.agent.installedVersions).toEqual([]);
+ expect(yield* isDeviceHubInstalled(base)).toBe(false);
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts
index 0493902302b3..efc6b2eead8e 100644
--- a/apps/server/src/device/DeviceToolchain.ts
+++ b/apps/server/src/device/DeviceToolchain.ts
@@ -1,3 +1,4 @@
+import type { DeviceToolVersions } from "@t3tools/contracts";
/**
* Pinned installs of the two external tools device support is built on.
*
@@ -221,3 +222,30 @@ export const isDeviceHubInstalled = (baseDir: string) =>
export const isAgentDeviceInstalled = (baseDir: string) =>
isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
+
+/** Read completed installs without downloading or starting either tool. */
+export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* (
+ baseDir: string,
+ running: { hub?: string; agent?: string } = {},
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) {
+ const directory = path.join(baseDir, "tools", spec.name);
+ const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
+ const versions = yield* Effect.filter(names, (version) =>
+ /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)
+ ? isInstalled(fs, toolPaths(path, baseDir, { ...spec, version }), version)
+ : Effect.succeed(false),
+ );
+ return {
+ requiredVersion: spec.version,
+ installedVersions: versions.sort(),
+ runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null,
+ };
+ });
+ return {
+ hub: yield* inspect(HUB_SPEC),
+ agent: yield* inspect(AGENT_DEVICE_SPEC),
+ } satisfies DeviceToolVersions;
+});
diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts
index b7341e04f8ad..0900bd80fa8e 100644
--- a/apps/server/src/device/LocalDeviceHost.ts
+++ b/apps/server/src/device/LocalDeviceHost.ts
@@ -50,6 +50,8 @@ import {
ensureDeviceHub,
isAgentDeviceInstalled,
isDeviceHubInstalled,
+ deviceToolVersions,
+ DEVICE_HUB_VERSION,
} from "./DeviceToolchain.ts";
const HUB_READY_TIMEOUT_MS = 30_000;
@@ -76,6 +78,7 @@ const AgentDeviceDaemonFile = Schema.Struct({
httpPort: Schema.Int,
token: Schema.String,
pid: Schema.optional(Schema.Int),
+ version: Schema.optional(Schema.String),
});
const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
@@ -232,7 +235,19 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
Effect.provideService(Path.Path, path),
),
]);
+ const running = yield* Ref.get(runningRef);
+ const daemon = running?.agentDevice
+ ? yield* readDaemonFile().pipe(Effect.option)
+ : Option.none();
+ const tools = yield* deviceToolVersions(config.baseDir, {
+ ...(running ? { hub: DEVICE_HUB_VERSION } : {}),
+ ...(Option.isSome(daemon) && daemon.value.version ? { agent: daemon.value.version } : {}),
+ }).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ );
return {
+ tools,
id: hostId,
kind: "local",
label: "This machine",
diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts
index 4ccda0fdefe9..5a42cbcbf5bb 100644
--- a/apps/server/src/device/SshDeviceHost.ts
+++ b/apps/server/src/device/SshDeviceHost.ts
@@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto";
import {
type DeviceHostSummary,
DevicePlatformAvailability,
+ DeviceToolVersions,
type SshDeviceHostConfig,
} from "@t3tools/contracts";
import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
@@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s
const Probe = Schema.Struct({
nodePath: Schema.String,
+ tools: Schema.optional(DeviceToolVersions),
platforms: Schema.Array(DevicePlatformAvailability),
});
const Started = Schema.Struct({
@@ -82,8 +84,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi
id: config.id,
label: config.label,
kind: "ssh",
- hubInstalled: false,
- agentDeviceInstalled: false,
+ tools: value.tools,
+ hubInstalled:
+ value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false,
+ agentDeviceInstalled:
+ value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false,
platforms: value.platforms,
} satisfies DeviceHostSummary;
});
@@ -183,6 +188,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
summary = {
...summary,
platforms: remote.platforms,
+ tools: remote.tools,
hubInstalled: true,
agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled,
};
diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts
index eadd85fd047d..fcd2f9087234 100644
--- a/apps/server/src/device/sshDeviceScript.test.ts
+++ b/apps/server/src/device/sshDeviceScript.test.ts
@@ -91,7 +91,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr
let invocation = 0;
const invoke = async (
owner: string,
- mode: "start" | "agent-start" | "stop-agent" | "stop",
+ mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
upgraded = false,
) => {
const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`);
@@ -107,6 +107,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr
});
return result.stdout ? JSON.parse(result.stdout) : null;
};
+ const inventory = await invoke("one", "probe");
+ expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]);
+ expect(inventory.tools.hub.runningVersion).toBeNull();
+ expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]);
+ await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow();
const template = NodePath.join(home, "hub-template");
await NodeFSP.cp(hubDir, template, { recursive: true });
await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts
index bbdb828c1a74..c86a8f2fb4b3 100644
--- a/apps/server/src/device/sshDeviceScript.ts
+++ b/apps/server/src/device/sshDeviceScript.ts
@@ -39,6 +39,27 @@ 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 toolVersions = (name, requiredVersion, entry, record) => {
+ const directory = path.join(root, 'tools');
+ const prefix = name + '@';
+ let names = [];
+ try { names = fs.readdirSync(directory); } catch {}
+ const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => {
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false;
+ const dir = path.join(directory, prefix + version);
+ try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch { return false; }
+ }).sort();
+ let runningVersion = null;
+ if (record?.entryPath && record?.pid) {
+ const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || '';
+ if (command.includes(record.entryPath)) runningVersion = installedVersions.find(version => record.entryPath === path.join(directory, prefix + version, 'node_modules', name, entry)) ?? null;
+ }
+ return { requiredVersion, installedVersions, runningVersion };
+};
+const versions = () => ({
+ hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))),
+ agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }),
+});
const stopHub = hub => {
if (!hub || hub.owner !== owner) return;
const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || '';
@@ -111,7 +132,7 @@ async function install(name, version, entry) {
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;
+ console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return;
}
fs.mkdirSync(state, { recursive: true, mode: 0o700 });
// Serialize starts and stops for this environment/host owner, including agent startup.
@@ -185,7 +206,7 @@ async function install(name, version, entry) {
}
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,
+ console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), 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/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx
new file mode 100644
index 000000000000..817862d0915e
--- /dev/null
+++ b/apps/web/src/components/device/DeviceToolVersions.tsx
@@ -0,0 +1,12 @@
+import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
+import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
+
+export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined }) {
+ return (
+
+ {deviceToolVersionLabels(tools).map((label) => (
+
{label}
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx
index 50841ee5b53a..d81ce316705b 100644
--- a/apps/web/src/components/settings/DeviceHostsSettings.tsx
+++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx
@@ -1,3 +1,4 @@
+import { DeviceToolVersions } from "../device/DeviceToolVersions";
import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
import { AppleIcon, AndroidIcon } from "../Icons";
import { Spinner } from "../ui/spinner";
@@ -236,6 +237,12 @@ function DeviceHostList({
))}
{host.target}
+ value.id === host.id)?.tools
+ }
+ />
{check?.status === "local" ? (
Already available locally
) : null}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx
index 60757724f2e4..f933abd403c4 100644
--- a/apps/web/src/components/settings/IntegrationsSettings.tsx
+++ b/apps/web/src/components/settings/IntegrationsSettings.tsx
@@ -1,3 +1,4 @@
+import { DeviceToolVersions } from "../device/DeviceToolVersions";
import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
import { ScopedSwitch } from "./ScopedSwitch";
import { DeviceHostsSettings } from "./DeviceHostsSettings";
@@ -775,6 +776,13 @@ function DeviceIntegrationControls({
{state.hostStatusDetail}
) : null}
+ host.kind === "local")?.tools} />
+ }
+ />
>
);
diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts
index 97312b671513..0626d1854336 100644
--- a/apps/web/src/components/settings/deviceHostConnectionChecks.ts
+++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts
@@ -14,7 +14,11 @@ export interface DeviceHostCheckTarget {
export type DeviceHostCheck =
| { status: "pending" }
| { status: "local" }
- | { status: "connected"; platforms: ReadonlyArray }
+ | {
+ status: "connected";
+ platforms: ReadonlyArray;
+ tools?: DeviceHostSummary["tools"];
+ }
| { status: "failed"; error: string };
const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig);
@@ -48,7 +52,11 @@ export async function checkDeviceHostConnections(
target.environmentId,
result.kind === "local"
? { status: "local" }
- : { status: "connected", platforms: result.platforms },
+ : {
+ status: "connected",
+ platforms: result.platforms,
+ ...(result.tools ? { tools: result.tools } : {}),
+ },
);
} catch (error) {
report(target.environmentId, {
diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts
index 1e6523497ef3..5e08de264de1 100644
--- a/packages/client-runtime/src/state/device.ts
+++ b/packages/client-runtime/src/state/device.ts
@@ -1,4 +1,4 @@
-import { WS_METHODS } from "@t3tools/contracts";
+import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";
import type { EnvironmentRegistry } from "../connection/registry.ts";
@@ -70,3 +70,17 @@ export function createDeviceEnvironmentAtoms(
}),
};
}
+
+/** Unknown inventory is distinct from a completed check that found no install. */
+export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) {
+ if (!tools) return ["Device tool versions have not been checked."];
+ return (
+ [
+ ["Device hub", tools.hub],
+ ["Agent tools", tools.agent],
+ ] as const
+ ).map(([name, tool]) => {
+ const installed = tool.installedVersions.length ? tool.installedVersions.join(", ") : "none";
+ return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`;
+ });
+}
diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts
index a93198a1e639..4c1b47862f37 100644
--- a/packages/contracts/src/device.ts
+++ b/packages/contracts/src/device.ts
@@ -74,11 +74,25 @@ export const DevicePlatformAvailability = Schema.Struct({
});
export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
+export const DeviceToolVersion = Schema.Struct({
+ requiredVersion: Schema.String,
+ installedVersions: Schema.Array(Schema.String),
+ runningVersion: Schema.NullOr(Schema.String),
+});
+export type DeviceToolVersion = typeof DeviceToolVersion.Type;
+
+export const DeviceToolVersions = Schema.Struct({
+ hub: DeviceToolVersion,
+ agent: DeviceToolVersion,
+});
+export type DeviceToolVersions = typeof DeviceToolVersions.Type;
+
export const DeviceHostSummary = Schema.Struct({
id: DeviceHostId,
kind: Schema.Literals(["local", "ssh"]),
label: TrimmedNonEmptyString,
platforms: Schema.Array(DevicePlatformAvailability),
+ tools: Schema.optional(DeviceToolVersions),
hubInstalled: Schema.Boolean,
agentDeviceInstalled: Schema.Boolean,
});
From a1e26be90691a4b01730f14a8466f4704cb68ad6 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 17:29:12 -0700
Subject: [PATCH 2/8] fix(devices): import optional daemon version helpers
---
apps/server/src/device/LocalDeviceHost.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts
index 0900bd80fa8e..5ad5db0b915d 100644
--- a/apps/server/src/device/LocalDeviceHost.ts
+++ b/apps/server/src/device/LocalDeviceHost.ts
@@ -31,6 +31,7 @@ import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
+import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
From 80e35d642e9dbce0a9bec285a06efe13dde1f589 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 17:41:26 -0700
Subject: [PATCH 3/8] fix(devices): detect agent daemon version across
entrypoints
---
apps/server/src/device/sshDeviceScript.test.ts | 6 +++++-
apps/server/src/device/sshDeviceScript.ts | 5 ++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts
index fcd2f9087234..566c4a21a372 100644
--- a/apps/server/src/device/sshDeviceScript.test.ts
+++ b/apps/server/src/device/sshDeviceScript.test.ts
@@ -83,9 +83,10 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir
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(); }
+else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'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(); }
`,
);
+ await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs"));
const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
let invocation = 0;
@@ -140,6 +141,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr
]);
expect(concurrentAgent.hubPort).toBe(first.hubPort);
expect(concurrentAgent.daemonPort).toBe(first.daemonPort);
+ const running = await invoke("one", "probe");
+ expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION);
+ expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION);
const second = await invoke("two", "agent-start");
const reused = await invoke("one", "agent-start");
expect(reused.hubPort).toBe(first.hubPort);
diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts
index c86a8f2fb4b3..e86f0413d89e 100644
--- a/apps/server/src/device/sshDeviceScript.ts
+++ b/apps/server/src/device/sshDeviceScript.ts
@@ -52,7 +52,10 @@ const toolVersions = (name, requiredVersion, entry, record) => {
let runningVersion = null;
if (record?.entryPath && record?.pid) {
const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || '';
- if (command.includes(record.entryPath)) runningVersion = installedVersions.find(version => record.entryPath === path.join(directory, prefix + version, 'node_modules', name, entry)) ?? null;
+ runningVersion = installedVersions.find(version => {
+ const install = path.join(directory, prefix + version);
+ return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep);
+ }) ?? null;
}
return { requiredVersion, installedVersions, runningVersion };
};
From 95cddcaeb5d1ea983516887baa4c4823110f8ce7 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 17:46:41 -0700
Subject: [PATCH 4/8] test(devices): isolate npm version probes in SSH fixture
---
apps/server/src/device/sshDeviceScript.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts
index 566c4a21a372..89021d8c0474 100644
--- a/apps/server/src/device/sshDeviceScript.test.ts
+++ b/apps/server/src/device/sshDeviceScript.test.ts
@@ -120,7 +120,7 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1
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});`,
+ `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`,
{ mode: 0o755 },
);
await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true });
From 4ec01f9a4ec8660066eac4b5f2b6730b47fe6861 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 17:49:02 -0700
Subject: [PATCH 5/8] fix(devices): inspect the active SSH host owner
---
apps/server/src/device/SshDeviceHost.test.ts | 8 +++++
apps/server/src/device/SshDeviceHost.ts | 33 ++++++++++++++------
2 files changed, 31 insertions(+), 10 deletions(-)
diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts
index d968d3383fae..71a15190ddc8 100644
--- a/apps/server/src/device/SshDeviceHost.test.ts
+++ b/apps/server/src/device/SshDeviceHost.test.ts
@@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ
const fs = yield* FileSystem.FileSystem;
const home = yield* fs.makeTempDirectoryScoped();
const modes: string[] = [];
+ const owners: string[] = [];
let forwards = 0;
let failForward = true;
let rejectConfig = true;
@@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ
);
const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
modes.push(mode);
+ owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? "");
output = JSON.stringify({
nodePath: "/node",
platforms: [{ platform: "ios", available: true }],
@@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ
),
);
yield* host.ensureReady(() => Effect.void);
+ yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe(
+ Effect.provide(ServerConfig.layerTest(home, home)),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+ expect(new Set(owners).size).toBe(1);
+ expect(owners[0]).toMatch(/^[a-f0-9]{24}$/);
expect(forwards).toBe(1);
expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
yield* host.platformAvailability("ios");
diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts
index 5a42cbcbf5bb..dadb38a02e6c 100644
--- a/apps/server/src/device/SshDeviceHost.ts
+++ b/apps/server/src/device/SshDeviceHost.ts
@@ -72,8 +72,23 @@ const bootstrap = (
),
);
-export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) {
- const result = yield* bootstrap(config, "probe", "probe");
+const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) {
+ const fs = yield* FileSystem.FileSystem;
+ const server = yield* ServerConfig.ServerConfig;
+ const environmentId = yield* fs
+ .readFileString(server.environmentIdPath)
+ .pipe(Effect.orElseSucceed(() => server.stateDir));
+ return NodeCrypto.createHash("sha256")
+ .update(`${environmentId}\0${server.stateDir}\0${hostId}`)
+ .digest("hex")
+ .slice(0, 24);
+});
+
+export const probe = Effect.fn("SshDeviceHost.probe")(function* (
+ config: SshDeviceHostConfig,
+ owner?: string,
+) {
+ const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe");
const value = yield* decodeProbe(result.stdout.trim()).pipe(
Effect.mapError(
(cause) =>
@@ -111,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
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 owner = yield* ownerFor(config.id);
const provide = (
effect: Effect.Effect<
A,
E,
- FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+ | FileSystem.FileSystem
+ | Path.Path
+ | ChildProcessSpawner.ChildProcessSpawner
+ | ServerConfig.ServerConfig
>,
) =>
effect.pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
+ Effect.provideService(ServerConfig.ServerConfig, server),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const lock = yield* Semaphore.make(1);
From 785f679b9b1690f86322d037e27b70847afbabc4 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 17:51:08 -0700
Subject: [PATCH 6/8] fix(devices): distinguish unknown inventory and stopped
helpers
---
.../server/src/device/DeviceToolchain.test.ts | 28 +++++++++++++++++--
apps/server/src/device/DeviceToolchain.ts | 28 +++++++++++++++----
apps/server/src/device/LocalDeviceHost.ts | 11 ++++++--
apps/server/src/device/sshDeviceScript.ts | 13 ++++++---
4 files changed, 66 insertions(+), 14 deletions(-)
diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts
index 8b8babed3269..d7812a92b48e 100644
--- a/apps/server/src/device/DeviceToolchain.test.ts
+++ b/apps/server/src/device/DeviceToolchain.test.ts
@@ -1,3 +1,4 @@
+import * as PlatformError from "effect/PlatformError";
import { expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
@@ -64,12 +65,35 @@ it.effect("inventory reports only completed versions without installing the requ
yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!);
}
const tools = yield* deviceToolVersions(base);
- expect(tools.hub).toEqual({
+ expect(tools?.hub).toEqual({
requiredVersion: DEVICE_HUB_VERSION,
installedVersions: ["0.9.0"],
runningVersion: null,
});
- expect(tools.agent.installedVersions).toEqual([]);
+ expect(tools?.agent.installedVersions).toEqual([]);
expect(yield* isDeviceHubInstalled(base)).toBe(false);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
+
+it.effect("unreadable inventory stays unknown instead of reporting no installs", () =>
+ Effect.gen(function* () {
+ const tools = yield* deviceToolVersions("/unreadable");
+ expect(tools).toBeUndefined();
+ }).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ readDirectory: () =>
+ Effect.fail(
+ PlatformError.systemError({
+ _tag: "PermissionDenied",
+ module: "FileSystem",
+ method: "readDirectory",
+ description: "denied",
+ }),
+ ),
+ }),
+ ),
+ Effect.provide(NodeServices.layer),
+ ),
+);
diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts
index efc6b2eead8e..81cdb79217fe 100644
--- a/apps/server/src/device/DeviceToolchain.ts
+++ b/apps/server/src/device/DeviceToolchain.ts
@@ -232,10 +232,24 @@ export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function
const path = yield* Path.Path;
const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) {
const directory = path.join(baseDir, "tools", spec.name);
- const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
+ const names = yield* fs.readDirectory(directory).pipe(
+ Effect.catchIf(
+ (error) => error.reason._tag === "NotFound",
+ () => Effect.succeed([]),
+ ),
+ );
const versions = yield* Effect.filter(names, (version) =>
/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)
- ? isInstalled(fs, toolPaths(path, baseDir, { ...spec, version }), version)
+ ? Effect.gen(function* () {
+ const paths = toolPaths(path, baseDir, { ...spec, version });
+ const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe(
+ Effect.catchIf(
+ (error) => error.reason._tag === "NotFound",
+ () => Effect.succeed(null),
+ ),
+ );
+ return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath));
+ })
: Effect.succeed(false),
);
return {
@@ -244,8 +258,10 @@ export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function
runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null,
};
});
- return {
- hub: yield* inspect(HUB_SPEC),
- agent: yield* inspect(AGENT_DEVICE_SPEC),
- } satisfies DeviceToolVersions;
+ return yield* Effect.gen(function* () {
+ return {
+ hub: yield* inspect(HUB_SPEC),
+ agent: yield* inspect(AGENT_DEVICE_SPEC),
+ } satisfies DeviceToolVersions;
+ }).pipe(Effect.orElseSucceed(() => undefined));
});
diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts
index 5ad5db0b915d..e84851d0722b 100644
--- a/apps/server/src/device/LocalDeviceHost.ts
+++ b/apps/server/src/device/LocalDeviceHost.ts
@@ -240,9 +240,16 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
const daemon = running?.agentDevice
? yield* readDaemonFile().pipe(Effect.option)
: Option.none();
+ const hubAlive = running
+ ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false))
+ : false;
+ const agentAlive =
+ Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false;
const tools = yield* deviceToolVersions(config.baseDir, {
- ...(running ? { hub: DEVICE_HUB_VERSION } : {}),
- ...(Option.isSome(daemon) && daemon.value.version ? { agent: daemon.value.version } : {}),
+ ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}),
+ ...(agentAlive && Option.isSome(daemon) && daemon.value.version
+ ? { agent: daemon.value.version }
+ : {}),
}).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts
index e86f0413d89e..4c156870a4f0 100644
--- a/apps/server/src/device/sshDeviceScript.ts
+++ b/apps/server/src/device/sshDeviceScript.ts
@@ -43,12 +43,14 @@ const toolVersions = (name, requiredVersion, entry, record) => {
const directory = path.join(root, 'tools');
const prefix = name + '@';
let names = [];
- try { names = fs.readdirSync(directory); } catch {}
+ try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; }
+ let unreadable = false;
const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => {
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false;
const dir = path.join(directory, prefix + version);
- try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch { return false; }
+ try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch (error) { if (error.code !== 'ENOENT') unreadable = true; return false; }
}).sort();
+ if (unreadable) return null;
let runningVersion = null;
if (record?.entryPath && record?.pid) {
const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || '';
@@ -59,10 +61,13 @@ const toolVersions = (name, requiredVersion, entry, record) => {
}
return { requiredVersion, installedVersions, runningVersion };
};
-const versions = () => ({
+const versions = () => {
+ const result = {
hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))),
agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }),
-});
+ };
+ return result.hub && result.agent ? result : undefined;
+};
const stopHub = hub => {
if (!hub || hub.owner !== owner) return;
const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || '';
From 49a29a62dafb006dbf6962e2a5303bb33de22a6b Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 18:31:58 -0700
Subject: [PATCH 7/8] fix(web): put device version details behind a compact
control
---
.../components/device/DeviceToolVersions.tsx | 41 ++++++++++++++++---
1 file changed, 35 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx
index 817862d0915e..b394361e1d01 100644
--- a/apps/web/src/components/device/DeviceToolVersions.tsx
+++ b/apps/web/src/components/device/DeviceToolVersions.tsx
@@ -1,12 +1,41 @@
import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
-import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
+import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";
export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined }) {
return (
-
- {deviceToolVersionLabels(tools).map((label) => (
-
{label}
- ))}
-
+
+
+ Versions
+
+
+ Device tools
+ {tools ? (
+
+ {(
+ [
+ ["Device hub", tools.hub],
+ ["Agent device", tools.agent],
+ ] as const
+ ).map(([name, tool]) => (
+
+
{name}
+
+ - Running
+ - {tool.runningVersion ?? "Not running"}
+ - Required
+ - {tool.requiredVersion}
+ - Installed
+ -
+ {tool.installedVersions.join(", ") || "None"}
+
+
+
+ ))}
+
+ ) : (
+ Versions have not been checked.
+ )}
+
+
);
}
From 0fef40be9e1edf04b62bef556c6fc15e9276d928 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 20 Sep 2026 20:21:49 -0700
Subject: [PATCH 8/8] fix(web): show device versions on their corresponding
settings rows
---
.../components/device/DeviceToolVersions.tsx | 68 +++++++++++++------
.../settings/IntegrationsSettings.tsx | 15 ++--
2 files changed, 57 insertions(+), 26 deletions(-)
diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx
index b394361e1d01..2ed11f7d9204 100644
--- a/apps/web/src/components/device/DeviceToolVersions.tsx
+++ b/apps/web/src/components/device/DeviceToolVersions.tsx
@@ -1,14 +1,42 @@
import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";
-export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined }) {
+export function DeviceToolVersions({
+ tools,
+ kind,
+}: {
+ tools: ToolVersions | undefined;
+ kind?: keyof ToolVersions;
+}) {
+ const selected = kind ? tools?.[kind] : undefined;
+ const version =
+ selected?.runningVersion ??
+ (selected?.installedVersions.includes(selected.requiredVersion)
+ ? selected.requiredVersion
+ : selected?.installedVersions
+ .toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .at(-1));
+ const label = kind === "hub" ? "Device hub" : "Agent device";
return (
-
- Versions
+
+ {kind
+ ? version
+ ? `v${version}`
+ : selected
+ ? "Not installed"
+ : "Version unknown"
+ : "Versions"}
- Device tools
+ {kind ? label : "Device tools"}
{tools ? (
{(
@@ -16,21 +44,23 @@ export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined
["Device hub", tools.hub],
["Agent device", tools.agent],
] as const
- ).map(([name, tool]) => (
-
-
{name}
-
- - Running
- - {tool.runningVersion ?? "Not running"}
- - Required
- - {tool.requiredVersion}
- - Installed
- -
- {tool.installedVersions.join(", ") || "None"}
-
-
-
- ))}
+ )
+ .filter(([name]) => !kind || name === label)
+ .map(([name, tool]) => (
+
+
{name}
+
+ - Running
+ - {tool.runningVersion ?? "Not running"}
+ - Required
+ - {tool.requiredVersion}
+ - Installed
+ -
+ {tool.installedVersions.join(", ") || "None"}
+
+
+
+ ))}
) : (
Versions have not been checked.
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx
index f933abd403c4..70c67853e903 100644
--- a/apps/web/src/components/settings/IntegrationsSettings.tsx
+++ b/apps/web/src/components/settings/IntegrationsSettings.tsx
@@ -693,6 +693,10 @@ function DeviceIntegrationControls({
description={deviceHubDescription}
control={
<>
+ host.kind === "local")?.tools}
+ />
{pending === "hub" ? : null}
+ host.kind === "local")?.tools}
+ />
{pending === "agent" ? : null}
) : null}
- host.kind === "local")?.tools} />
- }
- />
>
);