← Commit history
.gitignoreadded+2
@@ -0,0 +1,2 @@+target/+*.log
Cargo.lockadded+856
@@ -0,0 +1,856 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-usb"+version = "0.1.0"+dependencies = [+ "base64",+ "clap",+ "clap_complete",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys 0.61.2",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys 0.61.2",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "cc"+version = "1.2.65"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_complete"+version = "4.6.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"+dependencies = [+ "clap",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "displaydoc"+version = "0.2.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "icu_collections"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"++[[package]]+name = "icu_properties"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"+dependencies = [+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"++[[package]]+name = "icu_provider"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.186"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"++[[package]]+name = "litemap"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"++[[package]]+name = "log"+version = "0.4.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"++[[package]]+name = "memchr"+version = "2.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.40"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.14.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.13"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.150"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "shlex"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "smallvec"+version = "1.15.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.118"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "tinystr"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.8",+]++[[package]]+name = "webpki-roots"+version = "1.0.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"++[[package]]+name = "yoke"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zerofrom"+version = "0.1.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"++[[package]]+name = "zerotrie"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+24
@@ -0,0 +1,24 @@+[package]+name = "adom-usb"+version = "0.1.0"+edition = "2021"+description = "Adom USB device mesh — see every USB device across the Adom ecosystem (laptop, WSL2, cloud container, Azure VM, workcells) and mount/unmount/reset/test/sniff it from one dashboard"+license = "MIT"++[[bin]]+name = "adom-usb"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive", "env"] }+clap_complete = "4"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+base64 = "0.22"++[profile.release]+strip = true+lto = "thin"+opt-level = "z"
README.md+68−2
@@ -1,3 +1,69 @@-# Adom USB+# adom-usb -See every USB device across the entire Adom ecosystem — laptop, WSL2, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.+**See every USB device across the entire Adom ecosystem — and mount it anywhere.**++`adom-usb` is a Hydrogen webview app (+ CLI) that unifies USB across the whole+mesh: your **laptop**, its **WSL2 / Hydrogen Desktop**, this **cloud container**,+the **Azure VM** and its **WSL2**, and **workcells**. One table, Local/Cloud+filters, and per-device **mount / unmount / reset / test / force-class / sniff**.++It extends the same Linux USB/IP "slingshot" that already moves workcell USB into+cloud containers — now with the user's own machines as first-class sources and+targets.++![Dashboard](/blob/app/adom-usb/screenshots/hero.png)++## Run++```bash+adom-usb app                 # live: real lsusb on this container + registered agents+adom-usb app --demo          # seed a representative cross-ecosystem set for a tour+adom-usb list --demo         # unified inventory as JSON (no server)+adom-usb health              # is a dashboard server up?+```++Opens a Hydrogen tab named **USB Devices**. Columns: device · VID:PID · serial ·+class · source/location · current mount · target picker + actions.++## Drive it (every button is also an HTTP endpoint — AI-drivable)++```bash+adom-usb mount   laptop:2e8a:0003 laptop-wsl2     # RP2040 → laptop WSL2 (usbipd attach --wsl)+adom-usb mount   laptop:2e8a:0003 this-container  # RP2040 → cloud container (reverse tunnel)+adom-usb unmount laptop:2e8a:0003+curl -s localhost:8896/state | jq                 # hosts, devices, targets, mount state, action log+curl -sX POST localhost:8896/sniff -d '{"device_id":"..."}'+```++Each mount/op records the **exact agent command** it runs, visible in the+dashboard's action log.++## Routing matrix (v1)++Windows is **source + local-only**: a Windows box can export its USB and natively+use devices plugged into it, but a *remote* device cannot yet be mounted *into* a+native Windows desktop (needs the test-signed usbip-win client driver — those+targets show blocked). Linux targets (cloud container, laptop WSL2/HD, Azure+WSL2, workcells) accept remote mounts via `usbipd attach --wsl` (local) or the+adom-desktop **reverse tunnel** (cross-machine).++`reset` / `test` / `force-class` / `sniff` operate on the virtual device in a+**Linux target** — greyed out until the device is mounted on one.++## Architecture++- **Agents** (per host) expose `list / export / attach / detach / reset / class / sniff`,+  reached over the transport each host already has (adom-desktop relay for the+  laptop + its WSL2; `azure-winvm` for the Azure VM + its WSL2).+- **Transport**: same-machine = `usbipd attach --wsl`; cross-machine = the+  adom-desktop reverse tunnel; workcell→cloud = existing direct TCP.+- **Registry**: a generalized Curium broker (source_type/source_id) gives one+  unified inventory with live hot-plug.++See the project plan for the M1→M5 build sequence.++## Status++M1: unified inventory + mount overlay; each action emits the precise agent+command and wires to the live agents (usbipd-win over the relay, `usbip attach`+in the target, usbmon) as those land.
VERSIONadded+1
@@ -0,0 +1 @@+0.1.0
docs/icon.svgadded+9
@@ -0,0 +1,9 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">+  <path d="M12 21V5"/>+  <path d="M12 5l-2.4 3h4.8L12 5z" fill="currentColor" stroke="none"/>+  <circle cx="12" cy="22" r="0.9" fill="currentColor" stroke="none"/>+  <path d="M12 14l-4.2-2.4V9.2"/>+  <rect x="6.4" y="6.6" width="2.8" height="2.6" rx="0.4" fill="currentColor" stroke="none"/>+  <path d="M12 11l4.2-2.4V6.4"/>+  <circle cx="16.2" cy="5.4" r="1.1" fill="currentColor" stroke="none"/>+</svg>
docs/screenshot-main.pngadded
⋯ 1 unchanged line ⋯
install.shadded+16
@@ -0,0 +1,16 @@+#!/bin/bash+# install.sh for adom-usb (app) — runs from the extracted module dir.+set -euo pipefail+DIR="$(cd "$(dirname "$0")" && pwd)"; cd "$DIR"+BIN_DIR="${HOME}/.local/bin"; mkdir -p "$BIN_DIR"+install -m 0755 "$DIR/bin/adom-usb" "$BIN_DIR/adom-usb"+echo "Installed adom-usb -> $BIN_DIR/adom-usb"+SKILL_DIR="${HOME}/.claude/skills"+for s in adom-usb; do+  if [ -f "$DIR/skills/$s/SKILL.md" ]; then+    mkdir -p "$SKILL_DIR/$s"+    install -m 0644 "$DIR/skills/$s/SKILL.md" "$SKILL_DIR/$s/SKILL.md"+    echo "Installed skill -> $SKILL_DIR/$s/SKILL.md"+  fi+done+echo "adom-usb installed. Try: adom-usb app --demo"
package.jsonadded+34
@@ -0,0 +1,34 @@+{+  "slug": "adom-usb",+  "version": "0.1.0",+  "type": "app",+  "description": "See every USB device across the entire Adom ecosystem — laptop, WSL2 / Hydrogen Desktop, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.",+  "dependencies": {},+  "tags": [+    "usb",+    "usbip",+    "device-mesh",+    "hydrogen-desktop",+    "wsl2",+    "passthrough",+    "dashboard",+    "open-source",+    "mit"+  ],+  "license": "MIT",+  "needs_sudo": false,+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "files": [+    "bin/**",+    "skills/**",+    "install.sh",+    "uninstall.sh",+    "package.json",+    "page.json",+    "README.md",+    "screenshots/**"+  ]+}
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
skills/adom-usb/SKILL.mdadded+60
@@ -0,0 +1,60 @@+---+name: adom-usb+description: >+  USB device mesh dashboard. See every USB device across the whole Adom+  ecosystem — your laptop, its WSL2 / Hydrogen Desktop, this cloud container,+  the Azure VM and its WSL2, and workcells — in one Hydrogen webview, with+  Local/Cloud filters and per-device mount/unmount/reset/test/force-class/sniff.+  Trigger words: usb, usb devices, usb dashboard, list usb, mount usb, unmount+  usb, attach usb, usbip, usb/ip, slingshot usb, mount my dev board, rp2040 to+  wsl2, mount device to container, usb mesh, where is my usb device, force hid,+  force mass storage, sniff usb, usb passthrough, plug in dev board.+---++# adom-usb — USB device mesh dashboard++One table of every USB device across the Adom ecosystem and the controls to+route each one. Built as a Hydrogen webview app (see `app-creator`); every UI+action is also a plain HTTP endpoint, so the AI drives it exactly like the human.++## Run it++```bash+adom-usb app                 # live: real lsusb on this container + registered agents+adom-usb app --demo          # seed a representative cross-ecosystem set (RP2040, ST-Link, CP2102, FTDI, CH340)+adom-usb list --demo         # print the unified inventory as JSON (no server)+```++The dashboard opens as a Hydrogen tab named "USB Devices". Columns: name ·+VID:PID · serial · class · source/location · current mount · actions. Filter+chips: **Local / Cloud / All**.++## Drive it (same endpoints the buttons fire)++```bash+adom-usb mount  laptop:2e8a:0003 laptop-wsl2     # RP2040 → laptop WSL2 (usbipd attach --wsl)+adom-usb mount  laptop:2e8a:0003 this-container  # RP2040 → cloud container (reverse tunnel)+adom-usb unmount laptop:2e8a:0003+curl -s localhost:8896/state | jq            # full snapshot: hosts, devices, targets, actions+curl -sX POST localhost:8896/reset  -d '{"device_id":"..."}'+curl -sX POST localhost:8896/sniff  -d '{"device_id":"..."}'+```++## Routing matrix (v1)++Windows is **source + local-only**: a Windows machine can export its USB and+natively use devices physically plugged into it, but a *remote* device cannot be+mounted *into* a native Windows desktop yet (needs the test-signed usbip-win+client driver — those targets show as blocked). Linux targets (cloud container,+laptop WSL2/HD, Azure WSL2, workcells) accept remote mounts via `usbipd attach+--wsl` (local) or the adom-desktop reverse tunnel (cross-machine).++reset / test / force-class / sniff operate on the virtual device in a **Linux+target** — they are greyed out until the device is mounted on one.++## Status++M1 ships the unified inventory + the mount overlay; each action emits the exact+agent command it will run (visible in the action log) and is wired to the live+agents (usbipd-win over the relay, `usbip attach` in the target, usbmon) as+those land. See the project plan for the M1→M5 sequence.
src/app/mod.rsadded+333
@@ -0,0 +1,333 @@+//! `adom-usb app` — the Hydrogen webview USB device dashboard.+//!+//! One table of every USB device across the Adom ecosystem, with Local/Cloud+//! filters and per-device actions (mount/unmount/reset/test/force-class/sniff).+//! Server state is the single source of truth; the UI polls `GET /state` and+//! every button POSTs to an endpoint the AI can also drive with curl.+//!+//! M1 scope: live inventory + a mount-state overlay that records intent and+//! emits the exact agent command each action will run. The real agent calls+//! (usbipd-win over the relay, `usbip attach` in the target, usbmon, etc.) slot+//! into `agent_command()` / the action handlers as they are wired — the HTTP+//! contract here does not change when they land.++pub mod tab;++use crate::devices;+use crate::{note, ok, warn, AppArgs};+use serde_json::{json, Value};+use std::collections::{HashMap, VecDeque};+use std::sync::{Arc, Mutex};+use tiny_http::{Header, Method, Response, Server};++const UI_HTML: &str = include_str!("ui.html");+const ICON_SVG: &str = include_str!("../../docs/icon.svg");+const CONSOLE_MAX: usize = 500;+const ACTION_LOG_MAX: usize = 100;++struct AppState {+    demo: bool,+    dev: bool,+    /// device_id → Some(host) mounted / None unmounted (live overlay).+    mounts: Mutex<HashMap<String, Option<String>>>,+    actions: Mutex<VecDeque<Value>>,+    console: Mutex<VecDeque<Value>>,+    state_ts: Mutex<u128>,+    pending_eval: Mutex<Option<(String, String)>>,+    eval_results: Mutex<HashMap<String, Value>>,+}++fn now_ms() -> u128 {+    std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_millis())+        .unwrap_or(0)+}++fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body.to_string())+        .with_status_code(code)+        .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())+        .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())+        .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap())+}++fn read_body(req: &mut tiny_http::Request) -> Result<Value, String> {+    let mut body = String::new();+    std::io::Read::read_to_string(req.as_reader(), &mut body).map_err(|e| format!("read body: {}", e))?;+    if body.is_empty() { return Ok(Value::Null); }+    serde_json::from_str(&body).map_err(|e| format!("parse JSON: {}", e))+}++fn snapshot(state: &AppState) -> Value {+    let overlay = state.mounts.lock().unwrap().clone();+    devices::snapshot(state.demo, &overlay)+}++fn find_device(snap: &Value, id: &str) -> Option<Value> {+    snap.get("devices")?+        .as_array()?+        .iter()+        .find(|d| d.get("id").and_then(|v| v.as_str()) == Some(id))+        .cloned()+}++fn log_action(state: &AppState, entry: Value) {+    let mut a = state.actions.lock().unwrap();+    if a.len() >= ACTION_LOG_MAX { a.pop_front(); }+    a.push_back(entry);+    *state.state_ts.lock().unwrap() = now_ms();+}++/// The concrete command a live agent will run for an op. Surfaced in the action+/// log so a human (and the AI) can see exactly what the mount will execute, and+/// so it is trivially diffable against the eventual real implementation.+fn agent_command(op: &str, dev: &Value, target: &str, mechanism: &str) -> String {+    let bus = dev.get("bus_id").and_then(|v| v.as_str()).unwrap_or("?");+    let src = dev.get("source_id").and_then(|v| v.as_str()).unwrap_or("?");+    let vid = dev.get("vid_hex").and_then(|v| v.as_str()).unwrap_or("????");+    let pid = dev.get("pid_hex").and_then(|v| v.as_str()).unwrap_or("????");+    match (op, mechanism) {+        ("mount", "usbipd-attach-wsl") =>+            format!("[{src}] usbipd bind --busid {bus} && usbipd attach --wsl --busid {bus}"),+        ("mount", "reverse-tunnel") =>+            format!("[{src}] usbipd bind --busid {bus} ; adom-desktop tunnel --reverse --expose {src}:3240 ; [{target}] usbip attach -r 127.0.0.1 -b {bus}"),+        ("mount", "direct-tcp") =>+            format!("[{target}] usbip attach -r {src} -b {bus}"),+        ("mount", "native") =>+            format!("[{target}] (native) re-bind {vid}:{pid} to the host driver"),+        ("unmount", _) =>+            format!("[{target}] usbip detach (device {vid}:{pid}) ; [{src}] usbipd unbind --busid {bus}"),+        ("reset", _) => format!("[{target}] usb reset ioctl on {vid}:{pid} (detach+reattach)"),+        ("test", _) => format!("[{target}] lsusb -v -d {vid}:{pid} + enumerate/echo health check"),+        ("class_override", _) => format!("[{target}] echo <driver> > driver_override ; rebind {vid}:{pid}"),+        ("sniff", _) => format!("[{target}] modprobe usbmon ; capture usbmonX for {vid}:{pid} → pcap"),+        _ => format!("[{target}] {op} {vid}:{pid}"),+    }+}++pub fn run(args: AppArgs) -> Result<(), String> {+    ok(format!("adom-usb dashboard starting on port {}", args.port));+    if args.demo {+        note!("  {}demo mode: seeding a representative cross-ecosystem device set{}", crate::DIM, crate::RESET);+    } else {+        note!("  {}live mode: real lsusb on this container + registered agents only{}", crate::DIM, crate::RESET);+    }++    let state = Arc::new(AppState {+        demo: args.demo,+        dev: args.dev,+        mounts: Mutex::new(HashMap::new()),+        actions: Mutex::new(VecDeque::new()),+        console: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)),+        state_ts: Mutex::new(now_ms()),+        pending_eval: Mutex::new(None),+        eval_results: Mutex::new(HashMap::new()),+    });++    let addr = format!("0.0.0.0:{}", args.port);+    let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;++    if !args.no_tab {+        let proxy_url = tab::proxy_url(args.port);+        match tab::add_webview_tab(&args.tab_name, &proxy_url, ICON_SVG) {+            Ok(_) => ok(format!("opened Hydrogen tab '{}' → {}", args.tab_name, proxy_url)),+            Err(e) => warn(format!("couldn't open tab: {}. Manually: {}", e, proxy_url)),+        }+    }+    note!("  {}Press ctrl-c or POST /shutdown to stop.{}", crate::DIM, crate::RESET);++    for mut req in server.incoming_requests() {+        let url = req.url().to_string();+        let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).to_string();+        let method = req.method().clone();+        let st = Arc::clone(&state);++        let result: (u16, Value) = match (method.clone(), path.as_str()) {+            (Method::Get, "/") | (Method::Get, "/index.html") => {+                let _ = req.respond(+                    Response::from_string(UI_HTML).with_status_code(200)+                        .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())+                        .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())+                        .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),+                );+                continue;+            }+            (Method::Get, "/favicon.svg") => {+                let _ = req.respond(+                    Response::from_string(ICON_SVG).with_status_code(200)+                        .with_header(Header::from_bytes("Content-Type", "image/svg+xml").unwrap())+                        .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),+                );+                continue;+            }+            (Method::Get, "/health") => {+                let snap = snapshot(&st);+                let n = snap.get("devices").and_then(|d| d.as_array()).map(|a| a.len()).unwrap_or(0);+                (200, json!({"ok": true, "demo": st.demo, "devices": n, "version": env!("CARGO_PKG_VERSION")}))+            }+            (Method::Get, "/state") => {+                let mut snap = snapshot(&st);+                snap["dev"] = json!(st.dev);+                snap["state_ts"] = json!(*st.state_ts.lock().unwrap() as u64);+                snap["actions"] = json!(st.actions.lock().unwrap().iter().rev().take(20).cloned().collect::<Vec<_>>());+                (200, snap)+            }+            (Method::Post, "/refresh") => {+                *st.state_ts.lock().unwrap() = now_ms();+                (200, snapshot(&st))+            }+            (Method::Post, "/mount") => match read_body(&mut req) {+                Ok(body) => handle_mount(&st, &body),+                Err(e) => (400, json!({"error": e})),+            },+            (Method::Post, "/unmount") => match read_body(&mut req) {+                Ok(body) => handle_unmount(&st, &body),+                Err(e) => (400, json!({"error": e})),+            },+            (Method::Post, "/reset") => handle_device_op(&st, &mut req, "reset"),+            (Method::Post, "/test") => handle_device_op(&st, &mut req, "test"),+            (Method::Post, "/class-override") => handle_device_op(&st, &mut req, "class_override"),+            (Method::Post, "/sniff") => handle_device_op(&st, &mut req, "sniff"),+            (Method::Post, "/console") => match read_body(&mut req) {+                Ok(v) => {+                    let mut c = st.console.lock().unwrap();+                    if c.len() >= CONSOLE_MAX { c.pop_front(); }+                    c.push_back(v);+                    (200, json!({"ok": true}))+                }+                Err(e) => (400, json!({"error": e})),+            },+            (Method::Get, "/console") => {+                let c: Vec<Value> = st.console.lock().unwrap().iter().cloned().collect();+                (200, json!({"messages": c}))+            }+            (Method::Post, "/eval") if st.dev => match read_body(&mut req) {+                Ok(v) => {+                    let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();+                    let id = format!("ev{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos());+                    *st.pending_eval.lock().unwrap() = Some((id.clone(), code));+                    (200, json!({"id": id}))+                }+                Err(e) => (400, json!({"error": e})),+            },+            (Method::Get, "/eval/pending") if st.dev => {+                let p = st.pending_eval.lock().unwrap().take();+                match p {+                    Some((id, code)) => (200, json!({"id": id, "code": code})),+                    None => (200, json!({})),+                }+            }+            (Method::Post, path) if st.dev && path.starts_with("/eval/") && path.ends_with("/result") => {+                let id = path.trim_start_matches("/eval/").trim_end_matches("/result").to_string();+                match read_body(&mut req) {+                    Ok(v) => {+                        let r = v.get("result").cloned().unwrap_or(Value::Null);+                        st.eval_results.lock().unwrap().insert(id, r);+                        (200, json!({"ok": true}))+                    }+                    Err(e) => (400, json!({"error": e})),+                }+            }+            (Method::Post, "/shutdown") => {+                if !args.no_tab { let _ = tab::remove_webview_tab(&args.tab_name); }+                let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));+                ok("shutdown requested, exiting");+                std::process::exit(0);+            }+            _ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)})),+        };+        let _ = req.respond(json_resp(result.0, result.1));+    }+    Ok(())+}++fn handle_mount(st: &AppState, body: &Value) -> (u16, Value) {+    let device_id = match body.get("device_id").and_then(|v| v.as_str()) {+        Some(s) => s.to_string(),+        None => return (400, json!({"error": "device_id required"})),+    };+    let target_id = match body.get("target_id").and_then(|v| v.as_str()) {+        Some(s) => s.to_string(),+        None => return (400, json!({"error": "target_id required"})),+    };+    let snap = snapshot(st);+    let dev = match find_device(&snap, &device_id) {+        Some(d) => d,+        None => return (404, json!({"error": format!("unknown device: {}", device_id)})),+    };+    // Validate the target against the device's computed routing matrix.+    let targets = dev.get("targets").and_then(|v| v.as_array()).cloned().unwrap_or_default();+    let target = targets.iter().find(|t| t.get("host_id").and_then(|v| v.as_str()) == Some(target_id.as_str()));+    let target = match target {+        Some(t) => t.clone(),+        None => return (400, json!({"error": format!("{} cannot be mounted on {} (not in routing matrix)", device_id, target_id)})),+    };+    let blocked = target.get("blocked_reason").and_then(|v| v.as_str()).unwrap_or("");+    if !blocked.is_empty() {+        return (409, json!({"error": blocked, "blocked": true}));+    }+    let mechanism = target.get("mechanism").and_then(|v| v.as_str()).unwrap_or("native");+    let cmd = agent_command("mount", &dev, &target_id, mechanism);+    st.mounts.lock().unwrap().insert(device_id.clone(), Some(target_id.clone()));+    log_action(st, json!({+        "ts": now_ms() as u64, "op": "mount", "device_id": device_id,+        "device": dev.get("name"), "target": target_id, "mechanism": mechanism,+        "command": cmd, "status": "applied"+    }));+    (200, json!({"ok": true, "device_id": device_id, "mounted_on": target_id, "mechanism": mechanism, "command": cmd}))+}++fn handle_unmount(st: &AppState, body: &Value) -> (u16, Value) {+    let device_id = match body.get("device_id").and_then(|v| v.as_str()) {+        Some(s) => s.to_string(),+        None => return (400, json!({"error": "device_id required"})),+    };+    let snap = snapshot(st);+    let dev = match find_device(&snap, &device_id) {+        Some(d) => d,+        None => return (404, json!({"error": format!("unknown device: {}", device_id)})),+    };+    let was = dev.get("mounted_on").and_then(|v| v.as_str()).unwrap_or("").to_string();+    if was.is_empty() {+        return (409, json!({"error": "device is not mounted"}));+    }+    let cmd = agent_command("unmount", &dev, &was, "");+    st.mounts.lock().unwrap().insert(device_id.clone(), None);+    log_action(st, json!({+        "ts": now_ms() as u64, "op": "unmount", "device_id": device_id,+        "device": dev.get("name"), "target": was, "command": cmd, "status": "applied"+    }));+    (200, json!({"ok": true, "device_id": device_id, "command": cmd}))+}++fn handle_device_op(st: &AppState, req: &mut tiny_http::Request, op: &str) -> (u16, Value) {+    let body = match read_body(req) {+        Ok(b) => b,+        Err(e) => return (400, json!({"error": e})),+    };+    let device_id = match body.get("device_id").and_then(|v| v.as_str()) {+        Some(s) => s.to_string(),+        None => return (400, json!({"error": "device_id required"})),+    };+    let snap = snapshot(st);+    let dev = match find_device(&snap, &device_id) {+        Some(d) => d,+        None => return (404, json!({"error": format!("unknown device: {}", device_id)})),+    };+    let caps = dev.get("capabilities").cloned().unwrap_or(json!({}));+    let allowed = caps.get(op).and_then(|v| v.as_bool()).unwrap_or(false);+    if !allowed {+        let reason = caps.get("reason").and_then(|v| v.as_str())+            .unwrap_or("not available for this device in its current state");+        return (409, json!({"error": reason, "blocked": true}));+    }+    let target = dev.get("mounted_on").and_then(|v| v.as_str()).unwrap_or("");+    let cmd = agent_command(op, &dev, target, "");+    log_action(st, json!({+        "ts": now_ms() as u64, "op": op, "device_id": device_id,+        "device": dev.get("name"), "target": target, "command": cmd,+        "status": "queued"  // executes once the live agent for `target` is wired+    }));+    (200, json!({"ok": true, "op": op, "device_id": device_id, "command": cmd, "status": "queued"}))+}
src/app/tab.rsadded+134
@@ -0,0 +1,134 @@+//! Hydrogen tab lifecycle via `adom-cli hydrogen workspace`.++use base64::Engine;+use std::process::Command;++const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++/// Substitute the current port into VSCODE_PROXY_URI so the webview+/// loads via the Coder proxy (same-origin with hydrogen.adom.inc).+/// Falls back to 127.0.0.1 for local testing when the env var is not+/// set — `adom-parts-search app --no-tab` + `$ open http://127.0.0.1:8893`.+pub fn proxy_url(port: u16) -> String {+    match std::env::var("VSCODE_PROXY_URI") {+        Ok(tmpl) => tmpl.replace("{{port}}", &port.to_string()),+        Err(_) => format!("http://127.0.0.1:{}/", port),+    }+}++fn icon_data_url(icon_svg: &str) -> String {+    let b64 = base64::engine::general_purpose::STANDARD.encode(icon_svg.as_bytes());+    format!("data:image/svg+xml;base64,{}", b64)+}++pub fn add_webview_tab(name: &str, url: &str, icon_svg: &str) -> Result<(), String> {+    // Find the focused leaf we should attach to.+    let leaf = focused_leaf_for_tab()?;+    let initial_state = serde_json::json!({ "url": url }).to_string();+    let icon = icon_data_url(icon_svg);+    let out = Command::new("adom-cli")+        .args([+            "hydrogen",+            "workspace",+            "add-tab",+            "--panel-id",+            &leaf,+            "--panel-type",+            WEBVIEW_PANEL_TYPE,+            "--display-name",+            name,+            "--display-icon",+            &icon,+            "--initial-state",+            &initial_state,+        ])+        .output()+        .map_err(|e| format!("adom-cli add-tab: {}", e))?;+    if !out.status.success() {+        return Err(format!(+            "adom-cli add-tab failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++pub fn remove_webview_tab(name: &str) -> Result<(), String> {+    let out = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "remove-tab", "--name", name])+        .output()+        .map_err(|e| format!("adom-cli remove-tab: {}", e))?;+    if !out.status.success() {+        return Err(format!(+            "adom-cli remove-tab failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++/// Pick a leaf panel that is NOT the VS Code editor, so we don't cover+/// the user's code view. Walk the workspace tree, find nodes with+/// `type: "leaf"`, check whether any tab in the leaf is a VS Code+/// editor (`...eeee-4000-a000-...`). Return the first non-editor+/// leaf's id. Falls back to the focused panel id if nothing else fits.+fn focused_leaf_for_tab() -> Result<String, String> {+    let out = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "get"])+        .output()+        .map_err(|e| format!("adom-cli workspace get: {}", e))?;+    if !out.status.success() {+        return Err(format!("adom-cli workspace get failed: {}", String::from_utf8_lossy(&out.stderr)));+    }+    let v: serde_json::Value = serde_json::from_slice(&out.stdout)+        .map_err(|e| format!("parse workspace JSON: {}", e))?;++    let mut leaves: Vec<LeafInfo> = Vec::new();+    walk_leaves(&v, &mut leaves);++    // Prefer leaves that contain no VS Code editor tabs.+    let non_editor: Vec<&LeafInfo> = leaves.iter().filter(|l| !l.has_editor).collect();+    if let Some(l) = non_editor.first() {+        return Ok(l.id.clone());+    }+    if let Some(id) = v.get("focusedPanelId").and_then(|v| v.as_str()) {+        return Ok(id.to_string());+    }+    leaves+        .first()+        .map(|l| l.id.clone())+        .ok_or_else(|| "no panels found in workspace".into())+}++struct LeafInfo {+    id: String,+    has_editor: bool,+}++fn walk_leaves(v: &serde_json::Value, out: &mut Vec<LeafInfo>) {+    if let Some(obj) = v.as_object() {+        if obj.get("type").and_then(|v| v.as_str()) == Some("leaf") {+            if let Some(id) = obj.get("id").and_then(|v| v.as_str()) {+                let tabs = obj.get("tabs").and_then(|v| v.as_array());+                let has_editor = tabs+                    .map(|arr| {+                        arr.iter().any(|t| {+                            let pt = t.get("panelType").and_then(|v| v.as_str()).unwrap_or("");+                            // VS Code editor panel UUID has "-eeee-4000-a000-"+                            // or "-0030-4000-a000-". Web View is "-0031-".+                            pt.contains("eeee-4000") || pt.contains("0030-4000")+                        })+                    })+                    .unwrap_or(false);+                out.push(LeafInfo { id: id.to_string(), has_editor });+            }+        }+        for (_, child) in obj {+            walk_leaves(child, out);+        }+    } else if let Some(arr) = v.as_array() {+        for c in arr {+            walk_leaves(c, out);+        }+    }+}
src/app/ui.htmladded+371
@@ -0,0 +1,371 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8"/>+<meta name="viewport" content="width=device-width, initial-scale=1"/>+<title>USB Devices — Adom</title>+<link rel="icon" href="favicon.svg" type="image/svg+xml"/>+<style>+@import url("https://adom.inc/fonts/fonts.css");+:root{+  --bg:#0d1117; --surface:#161b22; --surface2:#1c2330; --border:#2a3038;+  --text:#e6edf3; --muted:#8b949e; --accent:#00b8b0; --green:#3fb950; --red:#f85149; --amber:#d29922;+  --radius:8px; --radius-lg:12px;+}+*{box-sizing:border-box}+html,body{margin:0;height:100%}+body{+  background:var(--bg); color:var(--text);+  font-family:"Satoshi","Familjen Grotesk",system-ui,sans-serif;+  font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased;+}+h1,h2,.headline{font-family:"Familjen Grotesk","Satoshi",sans-serif;font-weight:600}+.mono{font-family:"JetBrains Mono",ui-monospace,monospace}+button{font-family:inherit;cursor:pointer}+.wrap{max-width:1180px;margin:0 auto;padding:16px}++/* header — canonical single-row Adom app header */+header{display:flex;align-items:center;gap:12px;height:44px;padding:0 2px;margin-bottom:16px;+  position:relative;font-family:"Familjen Grotesk",sans-serif}+header .brand{display:flex;align-items:center;gap:8px;color:var(--accent);font-size:13px;font-weight:600;+  letter-spacing:.01em;cursor:default;flex-shrink:0;outline:none}+header .brand:hover .app-name{text-decoration:underline;text-decoration-color:var(--accent);text-underline-offset:3px}+header .brand .logo{width:18px;height:18px;flex-shrink:0;color:var(--text)}+header .brand .app-name{color:var(--accent)}+header .brand .app-subtitle{color:var(--muted);font-weight:500}+header .brand .sep{color:var(--muted);font-weight:400}+header .vsep{width:1px;height:18px;background:var(--border);flex-shrink:0}+header .subject{display:flex;align-items:center;gap:8px;min-width:0;flex:1;cursor:default}+header .subject-name{color:var(--text);font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+header .subject-meta{display:flex;align-items:center;gap:6px;color:var(--muted);font-family:"JetBrains Mono",monospace;font-size:11px;white-space:nowrap}+header .subject-meta .sep{color:var(--border)}+header .actions{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0}+@media (max-width:720px){header .subject-meta{display:none}}+header .hover-popover{position:absolute;top:calc(100% + 6px);left:0;background:var(--surface2);+  border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:12px 14px;+  min-width:300px;max-width:420px;font-family:"Satoshi",sans-serif;font-size:12px;color:var(--text);z-index:99999;+  opacity:0;transform:translateY(-2px);pointer-events:none;transition:opacity .12s ease,transform .12s ease}+header .hover-popover.show{opacity:1;transform:none;pointer-events:auto}+header .hover-popover .popover-title{font-family:"Familjen Grotesk";font-size:14px;font-weight:700;color:var(--text);margin-bottom:8px}+header .hover-popover .popover-row{display:flex;gap:12px;padding:3px 0;align-items:baseline}+header .hover-popover .popover-row .k{color:var(--muted);font-family:"JetBrains Mono",monospace;font-size:10px;width:60px;flex-shrink:0}+header .hover-popover .popover-row .v{color:var(--text);font-size:12px;flex:1}+header .hover-popover .popover-actions{margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:12px}+header .hover-popover .popover-link{color:var(--accent);text-decoration:none;font-family:"Familjen Grotesk";font-size:12px;font-weight:600}+header .hover-popover .popover-link:hover{text-decoration:underline}+.pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;+  font-size:11.5px;border:1px solid var(--border);background:var(--surface);color:var(--muted)}+.pill.demo{border-color:rgba(210,153,34,.4);color:var(--amber)}+.pill.live{border-color:rgba(63,185,80,.4);color:var(--green)}+.dot{width:7px;height:7px;border-radius:50%;background:currentColor}++/* toolbar */+.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}+.chips{display:inline-flex;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:3px}+.chip{border:none;background:transparent;color:var(--muted);padding:5px 12px;border-radius:6px;font-size:13px;transition:.15s}+.chip:hover{color:var(--text)}+.chip.on{background:var(--surface2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)}+.chip .ct{color:var(--muted);font-size:11px;margin-left:5px}+.toolbar .spacer{flex:1}+.btn{border:1px solid var(--border);background:var(--surface);color:var(--text);padding:6px 12px;+  border-radius:var(--radius);font-size:13px;transition:.15s;display:inline-flex;align-items:center;gap:6px}+.btn:hover{border-color:var(--accent);color:#fff}+.btn:disabled{opacity:.4;cursor:not-allowed}+.btn.ghost{background:transparent}+.btn svg{width:15px;height:15px}++/* table */+.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}+table{width:100%;border-collapse:collapse}+thead th{text-align:left;font-size:11px;text-transform:none;letter-spacing:.02em;color:var(--muted);+  font-weight:500;padding:10px 12px;border-bottom:1px solid var(--border);background:var(--surface2)}+tbody td{padding:11px 12px;border-bottom:1px solid var(--border);vertical-align:middle}+tbody tr:last-child td{border-bottom:none}+tbody tr:hover{background:rgba(255,255,255,.02)}+.dev-name{font-weight:600}+.dev-sub{color:var(--muted);font-size:12px;margin-top:1px}+.badge{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:6px;font-size:11.5px;+  border:1px solid var(--border);background:var(--surface2);color:var(--muted)}+.badge.local{color:#9ad0ff;border-color:rgba(56,139,253,.35)}+.badge.cloud{color:#c6a6ff;border-color:rgba(163,113,247,.35)}+.badge.mounted{color:var(--green);border-color:rgba(63,185,80,.35)}+.badge.unmounted{color:var(--muted)}+.badge.demo{color:var(--amber);border-color:rgba(210,153,34,.35)}+select.target{background:var(--surface2);color:var(--text);border:1px solid var(--border);+  border-radius:6px;padding:5px 8px;font-size:12.5px;max-width:200px}+select.target:disabled{opacity:.5}+.actions{display:flex;align-items:center;gap:5px;flex-wrap:wrap}+.iconbtn{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;+  border:1px solid var(--border);background:var(--surface2);color:var(--muted);border-radius:6px;transition:.15s}+.iconbtn:hover:not(:disabled){border-color:var(--accent);color:#fff}+.iconbtn:disabled{opacity:.35;cursor:not-allowed}+.iconbtn svg{width:15px;height:15px}+.mountbtn{padding:5px 10px;font-size:12.5px;border-radius:6px;border:1px solid var(--border);+  background:var(--accent);color:#04211f;font-weight:600;transition:.15s}+.mountbtn:hover:not(:disabled){filter:brightness(1.08)}+.mountbtn:disabled{opacity:.4;cursor:not-allowed}+.mountbtn.un{background:transparent;color:var(--text)}+select.cls{background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:11.5px}+.empty{padding:40px;text-align:center;color:var(--muted)}++/* action log */+.log{margin-top:16px}+.log summary{cursor:pointer;color:var(--muted);font-size:12.5px;padding:6px 0;list-style:none}+.log summary::-webkit-details-marker{display:none}+.log summary:hover{color:var(--text)}+.logbox{background:#0a0d12;border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;+  max-height:200px;overflow:auto;font-size:12px}+.logrow{padding:3px 0;border-bottom:1px solid rgba(255,255,255,.04);display:flex;gap:8px}+.logrow:last-child{border-bottom:none}+.logop{color:var(--accent);min-width:62px;font-weight:600}+.logcmd{color:var(--muted)}++/* tooltip (body-appended, 500ms delay, fade) */+#tip{position:fixed;z-index:99999;max-width:300px;background:#05080d;color:var(--text);+  border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.45;+  pointer-events:none;opacity:0;transition:opacity .18s ease;box-shadow:0 8px 24px rgba(0,0,0,.5)}+#tip.show{opacity:1}+.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#05080d;border:1px solid var(--border);+  padding:9px 14px;border-radius:8px;font-size:13px;opacity:0;transition:.2s;pointer-events:none;z-index:9999}+.toast.show{opacity:1}+.toast.err{border-color:rgba(248,81,73,.5);color:#ffb3ae}+</style>+</head>+<body>+<div class="wrap">+  <header>+    <div class="brand" data-tooltip-target="app-info" tabindex="0">+      <svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">+        <path d="M12 21V5"/><path d="M12 5l-2.4 3h4.8L12 5z" fill="currentColor" stroke="none"/>+        <circle cx="12" cy="22" r="0.9" fill="currentColor" stroke="none"/>+        <path d="M12 14l-4.2-2.4V9.2"/><rect x="6.4" y="6.6" width="2.8" height="2.6" rx="0.4" fill="currentColor" stroke="none"/>+        <path d="M12 11l4.2-2.4V6.4"/><circle cx="16.2" cy="5.4" r="1.1" fill="currentColor" stroke="none"/>+      </svg>+      <span class="app-name">adom-usb</span>+      <span class="sep">·</span>+      <span class="app-subtitle">USB device mesh</span>+    </div>+    <div class="vsep" aria-hidden="true"></div>+    <div class="subject">+      <span class="subject-name">Device mesh</span>+      <span class="subject-meta" id="subjectMeta"><span class="sep">·</span><span id="smTotal">0 devices</span></span>+    </div>+    <div class="actions">+      <span id="modepill" class="pill"><span class="dot"></span><span id="modetext">…</span></span>+    </div>++    <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+      <div class="popover-title">adom-usb</div>+      <div class="popover-row"><span class="k">about</span><span class="v">See every USB device across the Adom ecosystem — laptop, WSL2, cloud container, Azure VM, workcells — and mount / reset / test / force-class / sniff any of them from one place.</span></div>+      <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.0</span></div>+      <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+      <div class="popover-actions">+        <a class="popover-link" href="https://wiki.adom.inc/apps/adom-usb" target="_blank" rel="noopener">Open wiki ↗</a>+        <a class="popover-link" href="https://github.com/adom-inc/adom-usb" target="_blank" rel="noopener">GitHub ↗</a>+      </div>+    </div>+  </header>++  <div class="toolbar">+    <div class="chips" id="filters">+      <button class="chip on" data-f="all" aria-pressed="true" data-tooltip="Show every USB device across the whole mesh.">All <span class="ct" id="ct-all">0</span></button>+      <button class="chip" data-f="local" aria-pressed="false" data-tooltip="Local — devices physically plugged into your own machine (laptop and its WSL2 / Hydrogen Desktop).">Local <span class="ct" id="ct-local">0</span></button>+      <button class="chip" data-f="cloud" aria-pressed="false" data-tooltip="Cloud — devices on Adom-side hosts: this container, the Azure VM and its WSL2, and workcells.">Cloud <span class="ct" id="ct-cloud">0</span></button>+    </div>+    <div class="spacer"></div>+    <button class="btn ghost" id="refresh" data-tooltip="Re-enumerate USB devices across every host and refresh the table.">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 4v5h-5"/></svg>+      Refresh+    </button>+  </div>++  <div class="card">+    <table>+      <thead><tr>+        <th style="width:30%">Device</th>+        <th>VID:PID</th>+        <th>Serial</th>+        <th>Class</th>+        <th>Source / location</th>+        <th>Mounted on</th>+        <th style="width:280px">Mount target &amp; actions</th>+      </tr></thead>+      <tbody id="rows"><tr><td colspan="7" class="empty">Loading…</td></tr></tbody>+    </table>+  </div>++  <details class="log" id="logwrap">+    <summary id="logsummary">Action log — agent commands</summary>+    <div class="logbox" id="logbox"></div>+  </details>+</div>++<div id="tip"></div>+<div id="toast" class="toast"></div>++<script>+// ---- console forwarding (so the AI can read UI logs without DevTools) ----+(function(){+  const send=(level,args)=>{try{fetch('console',{method:'POST',headers:{'Content-Type':'application/json'},+    body:JSON.stringify({level,msg:args.map(a=>typeof a==='object'?JSON.stringify(a):String(a)).join(' '),ts:Date.now()})});}catch(e){}};+  ['log','warn','error'].forEach(l=>{const o=console[l].bind(console);console[l]=(...a)=>{o(...a);send(l,a);};});+  window.onerror=(m,s,ln,c,e)=>send('error',[m,'@',s+':'+ln,e&&e.stack||'']);+  window.addEventListener('unhandledrejection',ev=>send('error',['unhandledrejection',ev.reason&&ev.reason.stack||ev.reason]));+})();++// ---- tooltip engine: 500ms hover delay, fade, body-appended ----+const tip=document.getElementById('tip');let tipTimer=null,tipTarget=null;+function showTip(el){const t=el.getAttribute('data-tooltip');if(!t)return;tip.textContent=t;tip.classList.add('show');+  const r=el.getBoundingClientRect();let x=r.left,y=r.bottom+8;const tw=tip.offsetWidth,th=tip.offsetHeight;+  if(x+tw>innerWidth-8)x=innerWidth-tw-8;if(x<8)x=8;if(y+th>innerHeight-8)y=r.top-th-8;+  tip.style.left=x+'px';tip.style.top=Math.max(8,y)+'px';}+function hideTip(){tip.classList.remove('show');tipTarget=null;}+document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-tooltip]');if(!el){return;}+  if(el===tipTarget)return;tipTarget=el;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{if(tipTarget===el)showTip(el);},500);});+document.addEventListener('mouseout',e=>{const el=e.target.closest('[data-tooltip]');if(el&&el===tipTarget){clearTimeout(tipTimer);hideTip();}});+window.addEventListener('scroll',hideTip,true);++// ---- interactive header popover (hover-tooltip you can move into) ----+(function(){+  const SHOW_DELAY=600,HIDE_GRACE=220;+  document.querySelectorAll('header [data-tooltip-target]').forEach(trigger=>{+    const target=document.getElementById(trigger.dataset.tooltipTarget);if(!target)return;+    let showT=null,hideT=null;+    const show=()=>{clearTimeout(hideT);target.classList.add('show');};+    const queueShow=()=>{clearTimeout(hideT);showT=setTimeout(show,SHOW_DELAY);};+    const queueHide=()=>{clearTimeout(showT);hideT=setTimeout(()=>target.classList.remove('show'),HIDE_GRACE);};+    trigger.addEventListener('mouseenter',queueShow);+    trigger.addEventListener('mouseleave',queueHide);+    trigger.addEventListener('click',()=>{clearTimeout(showT);target.classList.toggle('show');});+    target.addEventListener('mouseenter',show);+    target.addEventListener('mouseleave',queueHide);+  });+})();++// ---- toast ----+const toastEl=document.getElementById('toast');let toastTimer=null;+function toast(msg,isErr){toastEl.textContent=msg;toastEl.classList.toggle('err',!!isErr);toastEl.classList.add('show');+  clearTimeout(toastTimer);toastTimer=setTimeout(()=>toastEl.classList.remove('show'),2600);}++// ---- state ----+let filter='all';let lastTs=-1;let picker={};  // device_id -> selected target host+const ICONS={+  reset:'<path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 4v5h-5"/>',+  test:'<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',+  sniff:'<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>'+};+function icon(p){return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'+p+'</svg>';}+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}++async function post(path,body){+  const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});+  const v=await r.json().catch(()=>({}));return{ok:r.ok,status:r.status,v};+}+async function doMount(id){+  const target=picker[id];if(!target){toast('pick a target first',true);return;}+  const{ok,v}=await post('mount',{device_id:id,target_id:target});+  if(ok){toast('mounted → '+target);}else{toast(v.error||'mount failed',true);}render();+}+async function doUnmount(id){const{ok,v}=await post('unmount',{device_id:id});+  if(ok){toast('unmounted');}else{toast(v.error||'unmount failed',true);}render();}+async function doOp(id,op){const path=op==='class_override'?'class-override':op;+  const{ok,v}=await post(path,{device_id:id});+  if(ok){toast(op+': '+(v.command||'queued'));}else{toast(v.error||(op+' failed'),true);}render();}++function setFilter(f){filter=f;document.querySelectorAll('.chip').forEach(c=>{const on=c.dataset.f===f;c.classList.toggle('on',on);c.setAttribute('aria-pressed',on?'true':'false');});render();}+document.getElementById('filters').addEventListener('click',e=>{const c=e.target.closest('.chip');if(c)setFilter(c.dataset.f);});+document.getElementById('refresh').addEventListener('click',async()=>{await post('refresh',{});toast('refreshed');force();});++let SNAP={hosts:[],devices:[],actions:[]};+function hostLabel(id){const h=SNAP.hosts.find(h=>h.id===id);return h?h.label:id;}++function render(){+  const counts={all:SNAP.devices.length,+    local:SNAP.devices.filter(d=>d.location==='local').length,+    cloud:SNAP.devices.filter(d=>d.location==='cloud').length};+  document.getElementById('ct-all').textContent=counts.all;+  document.getElementById('ct-local').textContent=counts.local;+  document.getElementById('ct-cloud').textContent=counts.cloud;++  const list=SNAP.devices.filter(d=>filter==='all'||d.location===filter);+  const rows=document.getElementById('rows');+  if(!list.length){rows.innerHTML='<tr><td colspan="7" class="empty">No '+esc(filter==='all'?'':filter)+' USB devices. Plug one in, or run <span class="mono">adom-usb app --demo</span>.</td></tr>';}+  else rows.innerHTML=list.map(rowHtml).join('');++  // action log+  const lb=document.getElementById('logbox');+  if(SNAP.actions&&SNAP.actions.length){+    lb.innerHTML=SNAP.actions.map(a=>'<div class="logrow"><span class="logop">'+esc(a.op)+'</span><span class="logcmd mono">'+esc(a.command||'')+'</span></div>').join('');+    document.getElementById('logsummary').textContent='Action log — '+SNAP.actions.length+' recent agent command'+(SNAP.actions.length>1?'s':'');+  }else{lb.innerHTML='<div class="logcmd">No actions yet. Mount a device to see the exact agent command it runs.</div>';}+}++function rowHtml(d){+  const caps=d.capabilities||{};+  const mounted=d.mounted_on;+  const sel=picker[d.id]||(d.targets[0]&&d.targets[0].host_id)||'';+  // target picker+  let opts=d.targets.map(t=>{+    const dis=t.blocked_reason?' disabled':'';+    const label=t.blocked_reason?(t.label+' — unavailable'):t.label;+    return '<option value="'+esc(t.host_id)+'"'+(t.host_id===sel?' selected':'')+dis+'>'+esc(label)+'</option>';+  }).join('');+  const selTip=(d.targets.find(t=>t.host_id===sel)||{}).mechanism;+  const mech={'usbipd-attach-wsl':'Local attach (usbipd --wsl), no relay','reverse-tunnel':'Cross-machine via adom-desktop reverse tunnel','native':'Native on the host it is plugged into','direct-tcp':'Direct USB/IP TCP'}[selTip]||'';++  const mountedRemote = mounted && mounted!==d.source_id;+  let mountCell;+  if(mountedRemote){+    mountCell='<button class="mountbtn un" onclick="doUnmount(\''+d.id+'\')" data-tooltip="Detach this device from '+esc(hostLabel(mounted))+' and return it to its source host ('+esc(d.source_label)+').">Unmount</button>';+  } else if(!d.exportable){+    mountCell='<span class="badge" data-tooltip="This device lives natively on '+esc(d.source_label)+' and has no remote export agent (cloud containers / WSL2 distros are consumers, not USB sources). Nothing to route.">native — on its host</span>';+  } else {+    mountCell='<select class="target" onchange="picker[\''+d.id+'\']=this.value;render()" data-tooltip="Choose where to mount this device. '+esc(mech)+'">'+opts+'</select>'++      '<button class="mountbtn" onclick="doMount(\''+d.id+'\')" data-tooltip="Mount this device on the selected target. '+esc(mech)+'.">Mount</button>';+  }++  const opBtn=(op,tipOn,tipOff)=>{+    const en=!!caps[op];+    return '<button class="iconbtn" '+(en?'':'disabled ')+'onclick="doOp(\''+d.id+'\',\''+op+'\')" data-tooltip="'+esc(en?tipOn:tipOff)+'">'+icon(ICONS[op]||'')+'</button>';+  };+  const offReason=caps.reason||'Mount to a Linux target first.';+  const clsSel='<select class="cls" id="cls-'+d.id+'" '+(caps.class_override?'':'disabled ')+'data-tooltip="Force the kernel to bind this device as a specific class regardless of its default, via driver_override. '+esc(caps.class_override?'':offReason)+'"><option value="hid">HID</option><option value="storage">Mass storage</option><option value="serial">Serial</option></select>';+  const clsBtn='<button class="iconbtn" '+(caps.class_override?'':'disabled ')+'onclick="doOp(\''+d.id+'\',\'class_override\')" data-tooltip="Apply the selected class override.">'+icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>')+'</button>';++  const srcBadge='<span class="badge '+esc(d.location)+'" data-tooltip="Physically plugged into '+esc(d.source_label)+'.">'+esc(d.source_label)+'</span>';+  const mountedBadge=mounted+    ?'<span class="badge mounted" data-tooltip="Currently attached to '+esc(hostLabel(mounted))+'.">'+esc(hostLabel(mounted))+'</span>'+    :'<span class="badge unmounted">— not mounted —</span>';+  const demoBadge=d.demo?' <span class="badge demo" data-tooltip="Seeded demo device (not real hardware). Run without --demo for live inventory only.">demo</span>':'';++  return '<tr>'++    '<td><div class="dev-name">'+esc(d.name)+demoBadge+'</div><div class="dev-sub">'+esc(d.class)+(d.bus_id?(' · bus '+esc(d.bus_id)):'')+'</div></td>'++    '<td class="mono">'+esc(d.vid_hex)+':'+esc(d.pid_hex)+'</td>'++    '<td class="mono">'+esc(d.serial||'—')+'</td>'++    '<td>'+esc(d.class)+'</td>'++    '<td>'+srcBadge+'</td>'++    '<td>'+mountedBadge+'</td>'++    '<td><div class="actions">'+mountCell++      opBtn('reset','Power-cycle / re-enumerate this device on '+esc(hostLabel(mounted||''))+'.',offReason)++      opBtn('test','Run lsusb -v + an enumerate/echo health check on the target.',offReason)++      clsSel+clsBtn++      opBtn('sniff','Capture USB packets (usbmon) for this device on the target → pcap.',offReason)++    '</div></td>'++  '</tr>';+}++async function poll(){+  try{+    const r=await fetch('state');const s=await r.json();+    document.getElementById('modetext').textContent=s.demo?'demo data':'live';+    const mp=document.getElementById('modepill');mp.className='pill '+(s.demo?'demo':'live');+    const c=s.counts||{};document.getElementById('smTotal').textContent=+      (c.total||0)+' device'+((c.total||0)===1?'':'s')+' · '+(c.local||0)+' local · '+(c.cloud||0)+' cloud · '+(c.mounted||0)+' mounted';+    if(s.state_ts!==lastTs){lastTs=s.state_ts;SNAP=s;render();}+  }catch(e){/* transient */}+}+function force(){lastTs=-1;poll();}+poll();setInterval(poll,600);+</script>+</body>+</html>
src/cli.rsadded+57
@@ -0,0 +1,57 @@+//! CLI surface for driving the dashboard from a terminal / from the AI.+//! `list` works standalone; `mount`/`unmount` POST to a running `app` server so+//! the webview updates live (same endpoints the UI buttons fire).++use crate::devices;+use crate::{hint, note, ok, MountArgs, UnmountArgs};+use std::collections::HashMap;++pub fn run_list(demo: bool) -> Result<(), String> {+    let snap = devices::snapshot(demo, &HashMap::new());+    note!("{}", serde_json::to_string_pretty(&snap).unwrap_or_default());+    Ok(())+}++fn post(port: u16, path: &str, body: serde_json::Value) -> Result<serde_json::Value, String> {+    let url = format!("http://127.0.0.1:{}{}", port, path);+    match ureq::post(&url).send_json(body) {+        Ok(resp) => resp.into_json::<serde_json::Value>().map_err(|e| format!("parse response: {}", e)),+        Err(ureq::Error::Status(code, resp)) => {+            let v: serde_json::Value = resp.into_json().unwrap_or(serde_json::json!({}));+            Err(format!("server returned {}: {}", code, v.get("error").and_then(|e| e.as_str()).unwrap_or("")))+        }+        Err(e) => Err(format!("could not reach the dashboard on port {} ({}). Start it with: adom-usb app", port, e)),+    }+}++pub fn run_mount(a: MountArgs) -> Result<(), String> {+    let resp = post(a.port, "/mount", serde_json::json!({"device_id": a.device_id, "target_id": a.target_id}))?;+    ok(format!("mounted {} → {}", a.device_id, a.target_id));+    if let Some(cmd) = resp.get("command").and_then(|v| v.as_str()) {+        note!("  agent command: {}", cmd);+    }+    Ok(())+}++pub fn run_health(port: u16) -> Result<(), String> {+    let url = format!("http://127.0.0.1:{}/health", port);+    match ureq::get(&url).call() {+        Ok(resp) => {+            let v: serde_json::Value = resp.into_json().unwrap_or(serde_json::json!({}));+            ok(format!("dashboard healthy on port {} (devices: {})", port,+                v.get("devices").and_then(|d| d.as_u64()).unwrap_or(0)));+            Ok(())+        }+        Err(e) => Err(format!("no dashboard on port {} ({}). Start it with: adom-usb app", port, e)),+    }+}++pub fn run_unmount(a: UnmountArgs) -> Result<(), String> {+    let resp = post(a.port, "/unmount", serde_json::json!({"device_id": a.device_id}))?;+    ok(format!("unmounted {}", a.device_id));+    if let Some(cmd) = resp.get("command").and_then(|v| v.as_str()) {+        note!("  agent command: {}", cmd);+    }+    hint("see `adom-usb list` or the dashboard for the updated mount state");+    Ok(())+}
src/devices.rsadded+297
@@ -0,0 +1,297 @@+//! The unified USB device inventory — the model behind the dashboard.+//!+//! A `Device` is one physical USB device, wherever it is physically plugged+//! in across the whole Adom ecosystem. Every device knows:+//!   - its identity (vid/pid/serial/class) — the `BoundDevice` shape that the+//!     workcell USB/IP feature already uses (hydrogen-desktop workcell.ts),+//!   - its `source` host (where it's physically plugged in),+//!   - where it can be mounted (`targets`) given the v1 routing matrix,+//!   - where it is *currently* mounted (`mounted_on`), and+//!   - which per-device actions are available (reset/test/class/sniff are+//!     Linux-target-only — greyed out for Windows-native devices).+//!+//! For M1 the live inventory comes from (a) a real `lsusb` enumeration of THIS+//! container and (b) any registered agents (laptop/Azure/workcell) once wired.+//! Until the agents land, `--demo` seeds a representative cross-ecosystem set+//! so the dashboard is fully demonstrable. Demo devices are flagged `demo:true`+//! and never silently masquerade as real hardware.++use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use std::process::Command;++/// A host in the mesh — a place a device can be plugged into and/or mounted.+#[derive(Clone, Serialize)]+pub struct Host {+    pub id: String,+    pub label: String,+    /// laptop | azure-vm | wsl2 | container | workcell+    pub kind: String,+    pub os: String,+    /// Filter bucket shown in the UI: "local" (user's own machine) or "cloud".+    pub location: String,+    /// Can this host *consume* a remote USB device as a mount target?+    pub can_consume: bool,+    /// Is this host a Linux target (so reset/test/class/sniff are possible)?+    pub linux: bool,+    /// Is the host's agent live, or is this a placeholder until wired?+    pub agent_online: bool,+}++/// A mount target option offered for a given device.+#[derive(Clone, Serialize)]+pub struct Target {+    pub host_id: String,+    pub label: String,+    /// usbipd-attach-wsl | reverse-tunnel | native | direct-tcp+    pub mechanism: String,+    /// Empty when available; a human reason when not (e.g. deferred).+    pub blocked_reason: String,+}++#[derive(Clone, Serialize, Deserialize)]+pub struct Device {+    pub id: String,+    pub name: String,+    pub vid: u16,+    pub pid: u16,+    pub serial: Option<String>,+    pub class: String,+    pub bus_id: Option<String>,+    /// host id where it is physically plugged in+    pub source_id: String,+    pub source_label: String,+    /// "local" | "cloud" — mirrors the source host's bucket+    pub location: String,+    /// host id it is currently mounted on, or None+    pub mounted_on: Option<String>,+    pub demo: bool,+}++/// The full inventory snapshot the UI renders from. `overlay` carries live+/// mount-state changes made this session (device_id → Some(host) mounted /+/// None unmounted), applied on top of the freshly enumerated inventory.+pub fn snapshot(demo: bool, overlay: &std::collections::HashMap<String, Option<String>>) -> Value {+    let hosts = hosts(demo);+    let mut devices = enumerate_local();+    if demo {+        devices.extend(demo_devices());+    }+    for d in devices.iter_mut() {+        if let Some(m) = overlay.get(&d.id) {+            d.mounted_on = m.clone();+        }+    }++    let devices_json: Vec<Value> = devices+        .iter()+        .map(|d| {+            let targets = targets_for(d, &hosts);+            // Exportable = can be slingshotted to a host other than its own source.+            let exportable = targets.iter().any(|t| t.host_id != d.source_id);+            let caps = capabilities_for(d, &hosts, exportable);+            let mut v = serde_json::to_value(d).unwrap_or(json!({}));+            v["exportable"] = json!(exportable);+            v["vid_hex"] = json!(format!("{:04X}", d.vid));+            v["pid_hex"] = json!(format!("{:04X}", d.pid));+            v["targets"] = serde_json::to_value(&targets).unwrap_or(json!([]));+            v["capabilities"] = caps;+            v+        })+        .collect();++    json!({+        "hosts": serde_json::to_value(&hosts).unwrap_or(json!([])),+        "devices": devices_json,+        "demo": demo,+        "counts": {+            "total": devices.len(),+            "local": devices.iter().filter(|d| d.location == "local").count(),+            "cloud": devices.iter().filter(|d| d.location == "cloud").count(),+            "mounted": devices.iter().filter(|d| d.mounted_on.is_some()).count(),+        }+    })+}++/// The hosts in the mesh. `this-container` is always real; the rest are agent+/// endpoints that come online as adom-desktop / azure-winvm / workcell register.+fn hosts(demo: bool) -> Vec<Host> {+    let online = |id: &str| -> bool {+        // TODO(agents): replace with a live registry probe (Curium /sources or+        // a direct ping over the adom-desktop relay / azure-winvm). In demo+        // mode everything is shown online so the matrix is explorable.+        demo || id == "this-container"+    };+    vec![+        Host { id: "laptop".into(), label: "John's laptop (Windows)".into(), kind: "laptop".into(),+            os: "windows".into(), location: "local".into(), can_consume: true, linux: false, agent_online: online("laptop") },+        Host { id: "laptop-wsl2".into(), label: "Laptop WSL2 / Hydrogen Desktop".into(), kind: "wsl2".into(),+            os: "linux".into(), location: "local".into(), can_consume: true, linux: true, agent_online: online("laptop-wsl2") },+        Host { id: "this-container".into(), label: "This cloud container".into(), kind: "container".into(),+            os: "linux".into(), location: "cloud".into(), can_consume: true, linux: true, agent_online: true },+        Host { id: "azure-vm".into(), label: "Azure VM desktop (Windows)".into(), kind: "azure-vm".into(),+            os: "windows".into(), location: "cloud".into(), can_consume: false, linux: false, agent_online: online("azure-vm") },+        Host { id: "azure-wsl2".into(), label: "Azure VM WSL2 / HD-on-VM".into(), kind: "wsl2".into(),+            os: "linux".into(), location: "cloud".into(), can_consume: true, linux: true, agent_online: online("azure-wsl2") },+        Host { id: "workcell-rpi-001".into(), label: "Workcell rpi-001".into(), kind: "workcell".into(),+            os: "linux".into(), location: "cloud".into(), can_consume: false, linux: true, agent_online: online("workcell-rpi-001") },+    ]+}++/// v1 routing matrix: which hosts a given device may be mounted to.+/// Windows is source + local-only — a remote device cannot mount INTO a native+/// Windows desktop in v1 (needs the test-signed usbip-win client driver).+fn targets_for(d: &Device, hosts: &[Host]) -> Vec<Target> {+    let src = hosts.iter().find(|h| h.id == d.source_id);+    let src_machine = src.map(machine_of).unwrap_or_default();++    // Only hosts with a USB *export* agent can slingshot a device elsewhere:+    // laptop / Azure VM (usbipd-win) and workcells (usbip-host). A device whose+    // source is a cloud container or a WSL2 distro is a consumer, not a source —+    // it stays native on the host it's already on.+    let exportable = matches!(src.map(|h| h.kind.as_str()), Some("laptop") | Some("azure-vm") | Some("workcell"));+    if !exportable {+        return vec![Target {+            host_id: d.source_id.clone(),+            label: format!("{} (native)", d.source_label),+            mechanism: "native".into(),+            blocked_reason: String::new(),+        }];+    }++    hosts+        .iter()+        .filter_map(|h| {+            if h.id == d.source_id && !h.linux {+                // Native use on the same Windows box it's plugged into.+                return Some(Target { host_id: h.id.clone(), label: format!("{} (native)", h.label),+                    mechanism: "native".into(), blocked_reason: String::new() });+            }+            if !h.can_consume {+                if h.os == "windows" {+                    return Some(Target { host_id: h.id.clone(), label: h.label.clone(),+                        mechanism: "usbip-win-client".into(),+                        blocked_reason: "Deferred in v1 — mounting a remote device into native Windows needs the test-signed usbip-win client driver.".into() });+                }+                return None;+            }+            let same_machine = machine_of(h) == src_machine && !src_machine.is_empty();+            let mechanism = if h.kind == "wsl2" && same_machine {+                "usbipd-attach-wsl" // local, no relay+            } else if h.os == "linux" {+                "reverse-tunnel" // cross-machine via adom-desktop tunnel+            } else {+                "native"+            };+            Some(Target { host_id: h.id.clone(), label: h.label.clone(),+                mechanism: mechanism.into(), blocked_reason: String::new() })+        })+        .collect()+}++/// Group hosts by the physical machine they live on, so "same machine" →+/// local usbipd attach --wsl rather than a relay tunnel.+fn machine_of(h: &Host) -> String {+    match h.id.as_str() {+        "laptop" | "laptop-wsl2" => "laptop-machine".into(),+        "azure-vm" | "azure-wsl2" => "azure-machine".into(),+        other => other.into(),+    }+}++/// reset/test/class-override/sniff operate on the virtual device in a Linux+/// target. Available only when the device is currently mounted on a Linux host.+fn capabilities_for(d: &Device, hosts: &[Host], exportable: bool) -> Value {+    let on_linux = d+        .mounted_on+        .as_ref()+        .and_then(|m| hosts.iter().find(|h| &h.id == m))+        .map(|h| h.linux)+        .unwrap_or(false);+    // "Mounted remotely" = slingshotted to a host other than its own source.+    // A device sitting native on its own host is home, not unmountable.+    let mounted_remote = d.mounted_on.as_deref().map(|m| m != d.source_id).unwrap_or(false);+    json!({+        "mount": exportable && !mounted_remote,+        "unmount": mounted_remote,+        "reset": on_linux,+        "test": on_linux,+        "class_override": on_linux,+        "sniff": on_linux,+        "reason": if on_linux { Value::Null } else { json!("Mount to a Linux target to reset / test / force-class / sniff.") }+    })+}++/// Real enumeration of THIS container's USB via `lsusb`. Usually empty in a+/// cloud container, which is itself informative (nothing physically attached).+fn enumerate_local() -> Vec<Device> {+    let out = match Command::new("lsusb").output() {+        Ok(o) if o.status.success() => o,+        _ => return Vec::new(),+    };+    String::from_utf8_lossy(&out.stdout)+        .lines()+        .filter_map(parse_lsusb_line)+        .collect()+}++/// `Bus 001 Device 002: ID 1d6b:0002 Linux Foundation 2.0 root hub`+fn parse_lsusb_line(line: &str) -> Option<Device> {+    let id_pos = line.find(" ID ")?;+    let rest = &line[id_pos + 4..];+    let (idpart, name) = rest.split_once(' ').unwrap_or((rest, ""));+    let (vid_s, pid_s) = idpart.split_once(':')?;+    let vid = u16::from_str_radix(vid_s.trim(), 16).ok()?;+    let pid = u16::from_str_radix(pid_s.trim(), 16).ok()?;+    // Root hubs are container plumbing, not user-actionable devices.+    if vid == 0x1d6b { return None; }+    let name = if name.trim().is_empty() { format!("{:04X}:{:04X} device", vid, pid) } else { name.trim().to_string() };+    Some(Device {+        id: format!("this-container:{:04x}:{:04x}", vid, pid),+        name,+        vid,+        pid,+        serial: None,+        class: classify(vid, pid),+        bus_id: None,+        source_id: "this-container".into(),+        source_label: "This cloud container".into(),+        location: "cloud".into(),+        mounted_on: Some("this-container".into()),+        demo: false,+    })+}++fn classify(vid: u16, _pid: u16) -> String {+    match vid {+        0x1d6b => "Hub".into(),+        _ => "USB device".into(),+    }+}++/// Representative cross-ecosystem devices for demoing the dashboard before the+/// live agents are wired. Includes the RP2040 from the canonical example.+fn demo_devices() -> Vec<Device> {+    vec![+        Device { id: "laptop:2e8a:0003".into(), name: "Raspberry Pi RP2040 (BOOTSEL)".into(),+            vid: 0x2e8a, pid: 0x0003, serial: Some("E660C0D1C7332A28".into()), class: "Mass storage (UF2)".into(),+            bus_id: Some("2-4".into()), source_id: "laptop".into(), source_label: "John's laptop (Windows)".into(),+            location: "local".into(), mounted_on: None, demo: true },+        Device { id: "laptop:0483:374b".into(), name: "ST-Link/V2.1 (STM32 debugger)".into(),+            vid: 0x0483, pid: 0x374b, serial: Some("066BFF495054".into()), class: "Vendor / CMSIS-DAP".into(),+            bus_id: Some("2-1".into()), source_id: "laptop".into(), source_label: "John's laptop (Windows)".into(),+            location: "local".into(), mounted_on: Some("laptop-wsl2".into()), demo: true },+        Device { id: "laptop:10c4:ea60".into(), name: "CP2102 USB-to-UART Bridge".into(),+            vid: 0x10c4, pid: 0xea60, serial: Some("0001".into()), class: "CDC serial".into(),+            bus_id: Some("2-3".into()), source_id: "laptop".into(), source_label: "John's laptop (Windows)".into(),+            location: "local".into(), mounted_on: None, demo: true },+        Device { id: "workcell-rpi-001:1a86:7523".into(), name: "CH340 Serial".into(),+            vid: 0x1a86, pid: 0x7523, serial: None, class: "CDC serial".into(),+            bus_id: Some("1-1".into()), source_id: "workcell-rpi-001".into(), source_label: "Workcell rpi-001".into(),+            location: "cloud".into(), mounted_on: Some("this-container".into()), demo: true },+        Device { id: "azure-vm:0403:6010".into(), name: "FTDI FT2232H (JTAG/serial)".into(),+            vid: 0x0403, pid: 0x6010, serial: Some("FT9AB2CD".into()), class: "Vendor / JTAG".into(),+            bus_id: Some("3-2".into()), source_id: "azure-vm".into(), source_label: "Azure VM desktop (Windows)".into(),+            location: "cloud".into(), mounted_on: None, demo: true },+    ]+}
src/install.rsadded+26
@@ -0,0 +1,26 @@+//! `adom-usb install` — deploy the user-facing skill so Claude Code discovers+//! the dashboard, mirroring the adom-parts-search install flow.++use crate::{note, ok};+use std::fs;+use std::path::PathBuf;++// SAFETY NET -- do not rely on for release propagation. On publish, `install`+// should fetch SKILL.md from the wiki as the primary source; this baked copy is+// only the offline fallback when the wiki is unreachable.+const SKILL_MD: &str = include_str!("../skills/adom-usb/SKILL.md");++fn skills_dir() -> PathBuf {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());+    PathBuf::from(home).join(".claude/skills/adom-usb")+}++pub fn install() -> Result<(), String> {+    let dir = skills_dir();+    fs::create_dir_all(&dir).map_err(|e| format!("create {}: {}", dir.display(), e))?;+    let dest = dir.join("SKILL.md");+    fs::write(&dest, SKILL_MD).map_err(|e| format!("write {}: {}", dest.display(), e))?;+    ok(format!("installed skill → {}", dest.display()));+    note!("  run `adom-usb app --demo` to open the dashboard");+    Ok(())+}
src/main.rsadded+137
@@ -0,0 +1,137 @@+//! adom-usb: the USB device mesh dashboard.+//!+//! See every USB device across the Adom ecosystem — your laptop, its WSL2 /+//! Hydrogen Desktop, this cloud container, the Azure VM and its WSL2, and+//! workcells — in one table, and mount / unmount / reset / test / force-class /+//! sniff any of them. The dashboard is a Hydrogen webview backed by a tiny HTTP+//! server; every button is also a plain HTTP endpoint, so the AI drives it the+//! same way the human does.++mod app;+mod cli;+mod devices;+mod install;++use clap::{CommandFactory, Parser, Subcommand};+use std::io::IsTerminal;+use std::process::ExitCode;+use std::sync::OnceLock;++pub const GREEN: &str = "\x1b[32m";+pub const RED: &str = "\x1b[31m";+pub const YELLOW: &str = "\x1b[33m";+pub const CYAN: &str = "\x1b[36m";+pub const BOLD: &str = "\x1b[1m";+pub const DIM: &str = "\x1b[2m";+pub const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+pub fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }+pub fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }+pub fn strip_ansi(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    let mut chars = s.chars().peekable();+    while let Some(c) = chars.next() {+        if c == '\x1b' && chars.peek() == Some(&'[') {+            chars.next();+            for c2 in chars.by_ref() { if c2.is_ascii_alphabetic() { break; } }+            continue;+        }+        out.push(c);+    }+    out+}+pub fn ok(m: impl std::fmt::Display) {+    if stdout_is_tty() { println!("{GREEN}{BOLD}OK:{RESET} {m}"); } else { println!("OK: {}", strip_ansi(&m.to_string())); }+}+pub fn err(m: impl std::fmt::Display) {+    if stderr_is_tty() { eprintln!("{RED}{BOLD}ERROR:{RESET} {m}"); } else { eprintln!("ERROR: {}", strip_ansi(&m.to_string())); }+}+pub fn warn(m: impl std::fmt::Display) {+    if stderr_is_tty() { eprintln!("{YELLOW}{BOLD}WARN:{RESET} {m}"); } else { eprintln!("WARN: {}", strip_ansi(&m.to_string())); }+}+pub fn hint(m: impl std::fmt::Display) {+    if stderr_is_tty() { eprintln!("{DIM}Hint:{RESET} {m}"); } else { eprintln!("Hint: {}", strip_ansi(&m.to_string())); }+}+#[macro_export]+macro_rules! note {+    ($($arg:tt)*) => {{+        let __s = format!($($arg)*);+        if $crate::stdout_is_tty() { println!("{}", __s); } else { println!("{}", $crate::strip_ansi(&__s)); }+    }};+}++#[derive(Parser)]+#[command(name = "adom-usb", version, about = "USB device mesh dashboard — see and route every USB device across the Adom ecosystem")]+pub struct Cli {+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Open the Hydrogen webview dashboard (starts the HTTP server).+    App(AppArgs),+    /// Print the unified device inventory as JSON (no server needed).+    List {+        /// Seed the representative cross-ecosystem demo set on top of real lsusb.+        #[arg(long)]+        demo: bool,+    },+    /// Mount a device onto a target host (POSTs to a running `app` server).+    Mount(MountArgs),+    /// Unmount a device from wherever it is attached.+    Unmount(UnmountArgs),+    /// Check whether a running dashboard server is reachable.+    Health {+        #[arg(long, default_value_t = 8896)]+        port: u16,+    },+    /// Install SKILL.md + bash completions.+    Install,+    /// Print shell completion script.+    Completions { shell: clap_complete::Shell },+}++#[derive(clap::Args)]+pub struct AppArgs {+    #[arg(long, default_value_t = 8896)] pub port: u16,+    #[arg(long, default_value = "USB Devices")] pub tab_name: String,+    #[arg(long)] pub no_tab: bool,+    /// Seed a representative cross-ecosystem device set so the dashboard is+    /// demonstrable before the live agents are wired.+    #[arg(long, env = "ADOM_USB_DEMO")] pub demo: bool,+    #[arg(long, env = "ADOM_USB_DEV")] pub dev: bool,+}++#[derive(clap::Args)]+pub struct MountArgs {+    pub device_id: String,+    pub target_id: String,+    #[arg(long, default_value_t = 8896)] pub port: u16,+}++#[derive(clap::Args)]+pub struct UnmountArgs {+    pub device_id: String,+    #[arg(long, default_value_t = 8896)] pub port: u16,+}++fn main() -> ExitCode {+    let args = Cli::parse();+    let result = match args.cmd {+        Cmd::App(a) => app::run(a),+        Cmd::List { demo } => cli::run_list(demo),+        Cmd::Mount(a) => cli::run_mount(a),+        Cmd::Unmount(a) => cli::run_unmount(a),+        Cmd::Health { port } => cli::run_health(port),+        Cmd::Install => install::install(),+        Cmd::Completions { shell } => {+            let mut cmd = Cli::command();+            clap_complete::generate(shell, &mut cmd, "adom-usb", &mut std::io::stdout());+            Ok(())+        }+    };+    match result { Ok(()) => ExitCode::SUCCESS, Err(e) => { err(&e); ExitCode::FAILURE } }+}
uninstall.shadded+6
@@ -0,0 +1,6 @@+#!/bin/bash+# uninstall.sh for adom-usb (app).+set -euo pipefail+rm -f "${HOME}/.local/bin/adom-usb" && echo "Removed adom-usb binary"+rm -rf "${HOME}/.claude/skills/adom-usb" && echo "Removed adom-usb skill"+echo "adom-usb uninstalled."
wiki/body.mdadded+54
@@ -0,0 +1,54 @@+# Adom USB++**See every USB device across the entire Adom ecosystem — and mount it anywhere.**++`adom-usb` is a Hydrogen webview app (+ CLI) that unifies USB across the whole+mesh: your **laptop**, its **WSL2 / Hydrogen Desktop**, this **cloud container**,+the **Azure VM** and its **WSL2**, and **workcells**. One table, Local/Cloud+filters, and per-device **mount / unmount / reset / test / force-class / sniff**.++It extends the same Linux USB/IP "slingshot" that already moves workcell USB into+cloud containers — now with the user's own machines as first-class sources and+targets.++## Run++```bash+adom-usb app                 # live: real lsusb on this container + registered agents+adom-usb app --demo          # seed a representative cross-ecosystem set for a tour+adom-usb list --demo         # unified inventory as JSON (no server)+adom-usb health              # is a dashboard server up?+```++Opens a Hydrogen tab named **USB Devices**. Columns: device · VID:PID · serial ·+class · source/location · current mount · target picker + actions.++## Drive it (every button is also an HTTP endpoint — AI-drivable)++```bash+adom-usb mount   laptop:2e8a:0003 laptop-wsl2     # RP2040 → laptop WSL2 (usbipd attach --wsl)+adom-usb mount   laptop:2e8a:0003 this-container  # RP2040 → cloud container (reverse tunnel)+adom-usb unmount laptop:2e8a:0003+curl -s localhost:8896/state | jq                 # hosts, devices, targets, mount state, action log+```++Each mount/op records the **exact agent command** it runs, visible in the+dashboard's action log.++## Routing matrix (v1)++Windows is **source + local-only**: a Windows box can export its USB and natively+use devices plugged into it, but a *remote* device cannot yet be mounted *into* a+native Windows desktop (needs the test-signed usbip-win client driver — those+targets show blocked). Linux targets (cloud container, laptop WSL2/HD, Azure+WSL2, workcells) accept remote mounts via `usbipd attach --wsl` (local) or the+adom-desktop **reverse tunnel** (cross-machine).++`reset` / `test` / `force-class` / `sniff` operate on the virtual device in a+**Linux target** — greyed out until the device is mounted on one.++## Status++M1 ships the unified inventory + mount overlay; each action emits the precise+agent command and wires to the live agents (usbipd-win over the relay,+`usbip attach` in the target, usbmon) as those land.
wiki/page.jsonadded+35
@@ -0,0 +1,35 @@+{+  "type": "app",+  "slug": "adom-usb",+  "title": "Adom USB",+  "brief": "See every USB device across the entire Adom ecosystem \u2014 laptop, WSL2, cloud container, Azure VM, workcells \u2014 in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.",+  "metadata": {+    "repo": "adom-inc/adom-usb",+    "version": "0.1.0",+    "releases": {+      "adom_docker": {+        "asset_name": "adom-usb",+        "binary_url": "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-usb/adom-usb",+        "install_hint": "curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-usb/adom-usb -o /tmp/adom-usb && chmod +x /tmp/adom-usb && sudo install -m 0755 /tmp/adom-usb /usr/local/bin/adom-usb && adom-usb install"+      }+    },+    "discovery_triggers": [+      "usb devices",+      "usb dashboard",+      "list usb",+      "mount usb",+      "attach usb",+      "usbip",+      "slingshot usb",+      "mount my dev board",+      "rp2040 to wsl2",+      "mount device to container",+      "usb mesh",+      "where is my usb device",+      "force hid",+      "sniff usb",+      "usb passthrough"+    ],+    "discovery_pitch": "One dashboard for every USB device across your laptop, WSL2 / Hydrogen Desktop, cloud container, Azure VM, and workcells \u2014 mount any device onto any host (usbipd attach --wsl locally, adom-desktop reverse tunnel across machines), then reset / test / force-class / sniff it."+  }+}\ No newline at end of file