diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index a88dd8401c..4bdd20f756 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -266,6 +266,11 @@ jobs: node-version: 22 cache: pnpm + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e80dd0751b..c8644cff8d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -153,6 +153,11 @@ jobs: node-version: 22 cache: pnpm + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/.gitignore b/.gitignore index 6b2f0197e4..41daac4568 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,6 @@ external/ /.flashgrep-index-engine/ .design/ + +# Generated by scripts/build-loopx.mjs (never commit the compiled binary) +src/apps/desktop/resources/loopx/ diff --git a/BitFun-Installer/src-tauri/Cargo.lock b/BitFun-Installer/src-tauri/Cargo.lock new file mode 100644 index 0000000000..ebb2922961 --- /dev/null +++ b/BitFun-Installer/src-tauri/Cargo.lock @@ -0,0 +1,5804 @@ +# 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 = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitfun-agent-stream" +version = "0.2.13" +dependencies = [ + "anyhow", + "async-trait", + "bitfun-events", + "futures", + "log", + "serde", + "serde_json", + "tokio", + "tokio-util", + "uuid", +] + +[[package]] +name = "bitfun-ai-adapters" +version = "0.2.13" +dependencies = [ + "anyhow", + "async-trait", + "bitfun-agent-stream", + "bitfun-core-types", + "chrono", + "eventsource-stream", + "futures", + "log", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "urlencoding", +] + +[[package]] +name = "bitfun-core-types" +version = "0.2.13" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "bitfun-events" +version = "0.2.13" +dependencies = [ + "anyhow", + "async-trait", + "bitfun-core-types", + "chrono", + "log", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "bitfun-installer" +version = "0.2.13" +dependencies = [ + "anyhow", + "bitfun-ai-adapters", + "chrono", + "dirs 5.0.1", + "eventsource-stream", + "flate2", + "futures", + "log", + "mslnk", + "reqwest 0.12.28", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tokio", + "tokio-stream", + "urlencoding", + "winreg 0.52.0", + "zip", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[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 = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mslnk" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86c97310150b7f496a93f31690da7822b99d95ff68ca9d30fb09d3ad54375c76" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "log", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[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 = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[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 2.0.119", + "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 2.0.119", + "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/MiniApp/Demo/git-graph/README.md b/MiniApp/Demo/git-graph/README.md index c2115c82d6..67b3d7b2f2 100644 --- a/MiniApp/Demo/git-graph/README.md +++ b/MiniApp/Demo/git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases BitFun MiniApp's full-stack collaboration capability — spe 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/MiniApp/Skills/miniapp-dev/SKILL.md b/MiniApp/Skills/miniapp-dev/SKILL.md index 8e291f9b4f..89c1464caf 100644 --- a/MiniApp/Skills/miniapp-dev/SKILL.md +++ b/MiniApp/Skills/miniapp-dev/SKILL.md @@ -121,10 +121,10 @@ src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx # InitMiniAppDispla ### Worker 宿主 ``` -src/apps/desktop/resources/worker_host.js +src/apps/desktop/resources/worker_host.cjs ``` -Node/Bun 标准脚本:从 argv 读策略 JSON,stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 +Node/Bun 标准脚本:从 `BITFUN_WORKER_POLICY` 环境变量读策略 JSON(argv[2] 仅作手动运行兜底),stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 ## MiniApp 数据模型 (V2) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 284b7f2ba3..f1237b0266 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,3 +52,41 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## loopx + +- Project: loopx +- Source: https://github.com/huangruiteng/loopx +- License: MIT +- Copyright: Copyright (c) 2026 LoopX contributors + +BitFun bundles a compiled, self-contained build of the loopx CLI as a desktop +sidecar resource (`resources/loopx/`). It powers the built-in bitfun-loopx +MiniApp's issue-fixing loop and is built at packaging time by +`scripts/build-loopx.mjs` from the pinned upstream release recorded in +`resources/loopx/manifest.json` (version, commit, content hash, and build +toolchain). The upstream license text and trademark policy ship alongside the +binary as `resources/loopx/LICENSE` and `resources/loopx/TRADEMARKS.md` in +binary release packages. The `loopx` name is used descriptively to refer to +the upstream project; bitfun-loopx is a third-party integration and is not a +LoopX project release. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index f3a45ca6ec..7f3fd18674 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "verify:webkit-compatibility": "node scripts/verify-webkit-compatibility.cjs", "verify:webkit-compatibility:test": "node --test scripts/verify-webkit-compatibility.test.mjs", "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility", + "build:loopx": "node scripts/build-loopx.mjs", "build:mobile-web": "pnpm --dir src/mobile-web build", "build:miniapp-market": "pnpm --dir src/miniapp-market-web build", "type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check", diff --git a/scripts/build-loopx.mjs b/scripts/build-loopx.mjs new file mode 100644 index 0000000000..c0c220179e --- /dev/null +++ b/scripts/build-loopx.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// Build the bundled loopx CLI for the BitFun desktop installer. +// +// Runs at BUILD time only (CI / packaging), never on user machines: fetches the +// pinned loopx source, compiles a self-contained onefile binary with +// PyInstaller, and stages it under src/apps/desktop/resources/loopx/ together +// with the compliance artifacts (MIT LICENSE, TRADEMARKS.md, provenance +// manifest). The desktop bundles that directory as a sidecar resource and the +// bitfun-loopx MiniApp worker prefers the bundled binary at runtime, so end +// users need neither Python nor git nor network access to use loopx. +// +// loopx is MIT (Copyright (c) 2026 LoopX contributors), pure-stdlib Python +// >= 3.11; PyInstaller's bootloader exception permits the bundled binary. + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Keep in sync with LOOPX_VENDOR_REF in the bitfun-loopx MiniApp worker.js: +// loopx's CLI JSON contract is the app's interface surface, so the bundled +// binary and the runtime vendor fallback must pin the same version. +const LOOPX_VERSION = process.env.LOOPX_VERSION || 'v0.2.13'; +const LOOPX_REPO = 'https://github.com/huangruiteng/loopx.git'; +const OUT_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'src', + 'apps', + 'desktop', + 'resources', + 'loopx', +); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + buildLoopx().catch((err) => { + console.error(`build-loopx failed: ${err.message}`); + process.exit(1); + }); +}function sh(cmd, args, opts = {}) { + execFileSync(cmd, args, { stdio: 'inherit', ...opts }); +} + +function shOut(cmd, args, opts = {}) { + return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }) + .toString() + .trim(); +} + +function sha256Of(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function pickPython() { + for (const candidate of [process.env.PYTHON, 'python', 'python3'].filter(Boolean)) { + try { + const version = shOut(candidate, ['--version']); + const m = version.match(/Python\s+(\d+)\.(\d+)/); + if (m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 11))) { + return { exe: candidate, version: version.replace(/\s+/g, ' ').trim() }; + } + console.warn(`build-loopx: ${candidate} is ${version} (Python >= 3.11 required), skipping`); + } catch { + // not installed / not on PATH + } + } + throw new Error('Python >= 3.11 not found (set PYTHON to a usable interpreter)'); +} + +export async function buildLoopx({ + version = LOOPX_VERSION, + outDir = OUT_DIR, +} = {}) { + const python = pickPython(); + console.log(`build-loopx: python ${python.version} (${python.exe})`); + try { + shOut('git', ['--version']); + } catch { + throw new Error('git not found on PATH'); + } + + const work = mkdtempSync(path.join(tmpdir(), 'loopx-build-')); + const src = path.join(work, 'src'); + const venv = path.join(work, 'venv'); + const dist = path.join(work, 'dist'); + try { + console.log(`build-loopx: cloning ${LOOPX_REPO} @ ${version}`); + sh('git', ['clone', '--depth', '1', '--branch', version, LOOPX_REPO, src]); + const commit = shOut('git', ['-C', src, 'rev-parse', 'HEAD']); + const described = shOut('git', ['-C', src, 'describe', '--tags', '--exact-match']); + if (described !== version) { + throw new Error(`pinned tag mismatch: expected ${version}, checkout is ${described}`); + } + if (!existsSync(path.join(src, 'LICENSE')) || !existsSync(path.join(src, 'loopx', 'cli.py'))) { + throw new Error('checkout is missing LICENSE or loopx/cli.py'); + } + + console.log('build-loopx: creating build venv and installing PyInstaller'); + sh(python.exe, ['-m', 'venv', venv]); + const pip = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pip.exe') + : path.join(venv, 'bin', 'pip'); + const pyinstaller = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pyinstaller.exe') + : path.join(venv, 'bin', 'pyinstaller'); + sh(pip, ['install', '--disable-pip-version-check', '--quiet', 'pyinstaller']); + + const entry = path.join(src, '_loopx_bundle_entry.py'); + writeFileSync(entry, 'from loopx.cli import main\nraise SystemExit(main())\n', 'utf8'); + + console.log('build-loopx: compiling onefile binary (PyInstaller)'); + sh(pyinstaller, [ + '--onefile', + '--name', 'loopx', + '--clean', + '--noconfirm', + '--distpath', dist, + '--workpath', path.join(work, 'build'), + '--specpath', path.join(work, 'build'), + path.basename(entry), + ], { cwd: src }); + + const binary = path.join(dist, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(binary)) throw new Error(`PyInstaller produced no binary at ${binary}`); + + console.log('build-loopx: staging into', outDir); + mkdirSync(outDir, { recursive: true }); + copyFileSync(binary, path.join(outDir, path.basename(binary))); + copyFileSync(path.join(src, 'LICENSE'), path.join(outDir, 'LICENSE')); + copyFileSync(path.join(src, 'TRADEMARKS.md'), path.join(outDir, 'TRADEMARKS.md')); + + const pyinstallerVersion = shOut(pyinstaller, ['--version']); + const manifest = { + schema_version: 1, + name: 'loopx', + version, + source: LOOPX_REPO.replace(/\.git$/, ''), + commit, + license: 'MIT', + copyright: 'Copyright (c) 2026 LoopX contributors', + sha256: `sha256:${sha256Of(path.join(outDir, path.basename(binary)))}`, + built_with: { + python: python.version, + pyinstaller: pyinstallerVersion, + }, + built_at: new Date().toISOString(), + }; + writeFileSync( + path.join(outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ); + + const sizeMb = (statSync(path.join(outDir, path.basename(binary))).size / 1048576).toFixed(1); + console.log(`build-loopx: done — ${path.join(outDir, path.basename(binary))} (${sizeMb} MiB, loopx ${version} @ ${commit})`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index d2ab567a26..3e11cd8d91 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -14,6 +14,7 @@ import { writeFileSync, } from 'fs'; import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; +import { buildLoopx } from './build-loopx.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -52,6 +53,7 @@ async function main() { desktopDir, ); process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; + const loopxResourceDir = await prepareBundledLoopx(forward, desktopDir); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; if (process.platform === 'darwin' && requestsDmgBundle(forward)) { @@ -63,6 +65,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + loopxResourceDir, resolution, releaseChannel, }); @@ -217,7 +220,7 @@ export function prepareMacOSFlashgrepForSigning( export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution, releaseChannel } + { desktopDir, flashgrepBinary, loopxResourceDir, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -229,6 +232,7 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + injectLoopxResource(config, loopxResourceDir); // The DeepSeek bridge is not a compile-time resource: cargo check and // desktop:dev must not require packages/dsh-acp/dist-profile. Official // packaging injects it here; frontend:build-all (beforeBuildCommand) @@ -320,6 +324,37 @@ function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { }; } +// The compiled loopx CLI sidecar is staged under resources/loopx/ and injected +// only when a real bundle is requested. tauri-build validates every resource +// path at cargo-build time, so a missing directory must never appear in the +// generated config (the bitfun-loopx MiniApp falls back to vendor/pip at +// runtime when the sidecar is absent, e.g. in dev builds). +function injectLoopxResource(config, loopxResourceDir) { + const resources = { ...(config.bundle?.resources || {}) }; + delete resources['resources/loopx/']; + if (loopxResourceDir) { + resources['resources/loopx/'] = 'resources/loopx/'; + } + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + +async function prepareBundledLoopx(forwardArgs, desktopDir) { + if (forwardArgs.includes('--no-bundle')) { + return null; + } + console.log('[tauri-build] Building the bundled loopx CLI sidecar (scripts/build-loopx.mjs)'); + await buildLoopx(); + const loopxDir = join(desktopDir, 'resources', 'loopx'); + const bin = join(loopxDir, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(bin)) { + throw new Error(`bundled loopx CLI missing after build at ${bin}`); + } + return loopxDir; +} + function bundledFlashgrepResources(primaryBinary) { const binaries = [primaryBinary]; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 1dd4350ac6..7f3e4f09a4 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -332,7 +332,7 @@ test('official packaging injects the DeepSeek profile resource', () => { mkdirSync(fixture, { recursive: true }); const baseConfig = join(fixture, 'tauri.conf.json'); writeFileSync(baseConfig, JSON.stringify({ - bundle: { resources: { 'resources/worker_host.js': 'resources/worker_host.js' } }, + bundle: { resources: { 'resources/worker_host.cjs': 'resources/worker_host.cjs' } }, })); try { const generated = prepareTauriConfig(baseConfig, { @@ -345,8 +345,8 @@ test('official packaging injects the DeepSeek profile resource', () => { 'resources/dsh-profile', ); assert.equal( - config.bundle.resources['resources/worker_host.js'], - 'resources/worker_host.js', + config.bundle.resources['resources/worker_host.cjs'], + 'resources/worker_host.cjs', ); } finally { rmSync(fixture, { force: true, recursive: true }); diff --git a/src/apps/desktop/resources/worker_host.js b/src/apps/desktop/resources/worker_host.cjs similarity index 94% rename from src/apps/desktop/resources/worker_host.js rename to src/apps/desktop/resources/worker_host.cjs index 4e9b4b4f63..827b218ab5 100644 --- a/src/apps/desktop/resources/worker_host.js +++ b/src/apps/desktop/resources/worker_host.cjs @@ -5,7 +5,7 @@ * stderr: JSON-RPC responses (one per line) * stdout: user console.log (forwarded to host) * - * Usage: node worker_host.js '' + * Usage: node worker_host.cjs (policy via BITFUN_WORKER_POLICY env; argv[2] accepted for manual runs) * Cwd: MiniApp app directory (contains source/worker.js, package.json, storage.json) */ @@ -15,7 +15,10 @@ const { exec: execCallback } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(execCallback); -const policy = JSON.parse(process.argv[2] || '{}'); +// Policy arrives through BITFUN_WORKER_POLICY (env) because Windows argv +// parsing does not round-trip multi-line JSON reliably. argv[2] stays as a +// convenience for manual runs. +const policy = JSON.parse(process.env.BITFUN_WORKER_POLICY || process.argv[2] || '{}'); const appDir = process.cwd(); const storagePath = path.join(appDir, 'storage.json'); diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 8b750a771a..296a8cd6e2 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -184,15 +184,31 @@ impl AppState { let worker_host_path = match resolve_worker_host_path() { Some(p) => { - log::info!("Resolved worker_host.js at: {}", p.display()); + log::info!("Resolved worker host at: {}", p.display()); p } None => { log::warn!( - "worker_host.js not found in any candidate location; \ + "worker host not found in any candidate location; \ MiniApp Workers will not start" ); - std::path::PathBuf::from("worker_host.js") + std::path::PathBuf::from("worker_host.cjs") + } + }; + // The bitfun-loopx MiniApp prefers a bundled, compiled loopx CLI + // sidecar (scripts/build-loopx.mjs, shipped via bundle.resources). + // Export its resource directory to the JS workers when present; the + // MiniApp falls back to vendor/pip acquisition when absent (e.g. dev). + let loopx_resource_dir = match resolve_bundled_loopx_dir() { + Some(dir) => { + log::info!("Resolved bundled loopx CLI resource dir: {}", dir.display()); + Some(dir) + } + None => { + log::info!( + "Bundled loopx CLI not found; bitfun-loopx will use vendor/pip acquisition" + ); + None } }; let speech_service = Arc::new(SpeechService::new(SpeechStoragePaths::new( @@ -200,11 +216,11 @@ impl AppState { path_manager.speech_model_downloads_dir(), path_manager.speech_input_temp_dir(), ))); - let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path) + let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path, loopx_resource_dir) .ok() .map(Arc::new); if js_worker_pool.is_none() { - log::warn!("JsWorkerPool not initialized (missing worker_host.js or no Bun/Node)"); + log::warn!("JsWorkerPool not initialized (missing worker host or no Bun/Node)"); } let statistics = Arc::new(RwLock::new(AppStatistics { @@ -549,48 +565,51 @@ impl AppState { } } -/// Try every layout we know about for `worker_host.js`, dev or bundled: -/// 1. `CARGO_MANIFEST_DIR/resources/worker_host.js` — `cargo run` / `tauri dev`. -/// 2. `/resources/worker_host.js` — generic side-by-side bundle. -/// 3. `/../Resources/resources/worker_host.js` — macOS `.app` (Tauri +/// Try every layout we know about for `worker_host.cjs`, dev or bundled: +/// 1. `CARGO_MANIFEST_DIR/resources/worker_host.cjs` — `cargo run` / `tauri dev`. +/// 2. `/resources/worker_host.cjs` — generic side-by-side bundle. +/// 3. `/../Resources/resources/worker_host.cjs` — macOS `.app` (Tauri /// copies bundle.resources into `Contents/Resources/`). -/// 4. `/../Resources/worker_host.js` — flat macOS layout fallback. -/// 5. `/../lib//resources/worker_host.js` — typical Linux deb/AppImage. -/// 6. `/../share//resources/worker_host.js` — alt Linux layout. +/// 4. `/../Resources/worker_host.cjs` — flat macOS layout fallback. +/// 5. `/../lib//resources/worker_host.cjs` — typical Linux deb/AppImage. +/// 6. `/../share//resources/worker_host.cjs` — alt Linux layout. +/// `.cjs` (not `.js`): the host is CommonJS and must stay that way regardless of +/// the nearest `package.json` ("type": "module" in this repo's root would make +/// Node treat a `.js` host as ESM and crash on `require`). fn resolve_worker_host_path() -> Option { let mut candidates: Vec = Vec::new(); candidates.push( std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); if let Ok(exe) = std::env::current_exe() { if let Some(exe_dir) = exe.parent() { - candidates.push(exe_dir.join("resources").join("worker_host.js")); + candidates.push(exe_dir.join("resources").join("worker_host.cjs")); if let Some(parent) = exe_dir.parent() { candidates.push( parent .join("Resources") .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); - candidates.push(parent.join("Resources").join("worker_host.js")); + candidates.push(parent.join("Resources").join("worker_host.cjs")); if let Some(bin_name) = exe.file_name().and_then(|s| s.to_str()) { candidates.push( parent .join("lib") .join(bin_name) .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); candidates.push( parent .join("share") .join(bin_name) .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); } } @@ -599,3 +618,44 @@ fn resolve_worker_host_path() -> Option { candidates.into_iter().find(|p| p.exists()) } + +/// Resolve the directory hosting the bundled, compiled loopx CLI sidecar +/// (built by `scripts/build-loopx.mjs`, shipped via `bundle.resources`). The +/// layouts mirror `resolve_worker_host_path`, with the platform binary name +/// under a `loopx/` subdirectory. Returns the resource directory so the +/// worker pool can export it as `BITFUN_RESOURCE_DIR`. +fn resolve_bundled_loopx_dir() -> Option { + let bin_name = if cfg!(windows) { "loopx.exe" } else { "loopx" }; + let mut candidates: Vec = Vec::new(); + + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("loopx"), + ); + + if let Ok(exe) = std::env::current_exe() { + if let Some(exe_dir) = exe.parent() { + candidates.push(exe_dir.join("resources").join("loopx")); + if let Some(parent) = exe_dir.parent() { + candidates.push(parent.join("Resources").join("resources").join("loopx")); + candidates.push(parent.join("Resources").join("loopx")); + if let Some(bin) = exe.file_name().and_then(|s| s.to_str()) { + candidates.push(parent.join("lib").join(bin).join("resources").join("loopx")); + candidates.push( + parent + .join("share") + .join(bin) + .join("resources") + .join("loopx"), + ); + } + } + } + } + + candidates + .into_iter() + .find(|dir| dir.join(bin_name).exists()) + .map(|dir| dir.parent().map(|p| p.to_path_buf()).unwrap_or(dir)) +} diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 8ec3c4515d..87dddb1f27 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -19,7 +19,7 @@ ], "resources": { "../../mobile-web/dist": "mobile-web/dist", - "resources/worker_host.js": "resources/worker_host.js", + "resources/worker_host.cjs": "resources/worker_host.cjs", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", "../../crates/services/services-integrations/assets/models-dev.provenance.json": "third-party/models.dev/provenance.json" diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json index c7959fbf1d..6369294305 100644 --- a/src/apps/desktop/tauri.dev.conf.json +++ b/src/apps/desktop/tauri.dev.conf.json @@ -18,7 +18,7 @@ ], "resources": { "../../mobile-web/dist": "mobile-web/dist", - "resources/worker_host.js": "resources/worker_host.js", + "resources/worker_host.cjs": "resources/worker_host.cjs", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", "../../crates/services/services-integrations/assets/models-dev.provenance.json": "third-party/models.dev/provenance.json" diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md index c2115c82d6..67b3d7b2f2 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases BitFun MiniApp's full-stack collaboration capability — spe 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs index c41e0b9abc..8c9da26241 100644 --- a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs @@ -29,11 +29,13 @@ impl JsWorkerPool { pub fn new( path_manager: Arc, worker_host_path: PathBuf, + resource_dir: Option, ) -> BitFunResult { let event_sink: SharedMiniAppWorkerEventSink = Arc::new(CoreMiniAppWorkerEventSink); ServiceJsWorkerPool::new( path_manager.miniapps_dir(), worker_host_path, + resource_dir, Some(event_sink), ) .map(|inner| Self { inner }) @@ -179,6 +181,7 @@ impl JsWorkerPool { inner: ServiceJsWorkerPool::from_runtime( path_manager.miniapps_dir(), worker_host_path, + None, runtime, Some(Arc::new(CoreMiniAppWorkerEventSink)), ), diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 693aec8c4d..8723e273d0 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -159,6 +159,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ worker_js: include_str!("builtin/assets/ppt-live/worker.js"), esm_dependencies_json: include_str!("builtin/assets/ppt-live/esm_dependencies.json"), }, + BuiltinMiniAppBundle { + id: "builtin-bitfun-loopx", + version: 1, + meta_json: include_str!("builtin/assets/bitfun-loopx/meta.json"), + html: include_str!("builtin/assets/bitfun-loopx/index.html"), + css: include_str!("builtin/assets/bitfun-loopx/style.css"), + ui_js: include_str!("builtin/assets/bitfun-loopx/ui.js"), + worker_js: include_str!("builtin/assets/bitfun-loopx/worker.js"), + esm_dependencies_json: include_str!("builtin/assets/bitfun-loopx/esm_dependencies.json"), + }, ]; pub fn builtin_content_hash(app: &BuiltinMiniAppBundle) -> String { @@ -368,6 +378,7 @@ mod tests { "builtin-regex-playground", "builtin-coding-selfie", "builtin-ppt-live", + "builtin-bitfun-loopx", ] ); @@ -381,6 +392,56 @@ mod tests { } } + #[test] + fn builtin_miniapp_meta_and_deps_parse_into_product_types() { + use crate::miniapp::types::{EsmDep, MiniAppMeta}; + + for app in BUILTIN_APPS { + let meta: MiniAppMeta = serde_json::from_str(app.meta_json).unwrap_or_else(|e| { + panic!("builtin '{}' meta.json does not parse as MiniAppMeta: {e}", app.id) + }); + assert_eq!(meta.id, app.id, "builtin '{}' meta id must match the bundle id", app.id); + assert!( + !meta.name.trim().is_empty(), + "builtin '{}' meta name must not be empty", + app.id + ); + assert!( + !meta.icon.trim().is_empty(), + "builtin '{}' meta icon must not be empty", + app.id + ); + let i18n = meta.i18n.as_ref().unwrap_or_else(|| { + panic!("builtin '{}' meta.json must carry i18n locales", app.id) + }); + assert!( + i18n.locales.contains_key("zh-CN"), + "builtin '{}' i18n must cover zh-CN", + app.id + ); + assert!( + i18n.locales.contains_key("en-US"), + "builtin '{}' i18n must cover en-US", + app.id + ); + + let deps: Vec = serde_json::from_str(app.esm_dependencies_json) + .unwrap_or_else(|e| { + panic!( + "builtin '{}' esm_dependencies.json does not parse as an ESM dep list: {e}", + app.id + ) + }); + for dep in &deps { + assert!( + !dep.name.trim().is_empty(), + "builtin '{}' has an ESM dependency without a name", + app.id + ); + } + } + } + #[derive(Default)] struct FakeSeedHost { now_ms: i64, diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md new file mode 100644 index 0000000000..27f42d78a2 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md @@ -0,0 +1,117 @@ +# bitfun-loopx(内置 MiniApp) + +内置版 **bitfun-loopx**:粘贴 GitHub Issue 链接,由 BitFun 宿主 Agent 驱动本机 +LoopX 引擎持续修复,心跳调度、人工审批、中途插话。 + +本目录就是该内置 MiniApp 的**唯一权威源码**,不依赖任何外部仓库快照。五件套 +`index.html` / `style.css` / `ui.js` / `worker.js` / `esm_dependencies.json` 由 +`src/crates/contracts/product-domains/src/miniapp/builtin.rs` 的 `BUILTIN_APPS` +以 `include_str!` 嵌入二进制,注册 id 为 `builtin-bitfun-loopx`(同文件契约测试 +含 id 顺序断言);`meta.json` 的权限与注册条目保持一致。 + +## 修改流程 + +直接编辑本目录文件(`index.html` / `style.css` / `ui.js` / `worker.js` / +`meta.json`): + +1. **开发期无需 bump version**:种子机制按内容哈希判定,重启应用即自动 reseed + (跳过条件 = 内容哈希一致 且 已装 version ≥ 内置 version;内容一变哈希必变)。 +2. **发布期(每个发布版)**:把 `builtin.rs` 的 `BUILTIN_APPS` version 与 + `meta.json` 的 `version` **同步 +1**。version 只是单调计数,唯一作用是给 + "本地定制"用户发更新通知,不代表内容新旧。 +3. 本地验证 reseed:重启应用(或删除 + `~/.bitfun/miniapps/builtin-bitfun-loopx/.builtin-manifest.json` 后重启), + 确认新资源落盘; +4. `cargo test -p bitfun-product-domains --features product-full builtin_miniapp` 全绿后提交。 + +规范依据:`MiniApp/Skills/miniapp-dev/SKILL.md` 的「内置小应用(builtin/assets/*) +维护规范」。 + +## 内置更新行为 + +- 内容哈希变化(无论 version 是否 bump)都会在下次启动时 reseed:用户机器上的 + 源文件被覆盖为最新内置版本; +- 用户对源文件的**手动本地修改会被覆盖**,除非该应用处于"本地定制(local + override)"状态——那时只记录"有可用更新"通知(可拒绝),不改动本地内容; +- `storage.json`(设置、GitHub Token、goal↔session 映射等)跨 reseed 始终保留。 + +## 权限与信任模型 + +`meta.json` 的 `permissions` 约束的是宿主桥接原语(`app.shell.exec` / +`app.net.fetch` / `app.fs.*` 等)。本应用的 worker 是**受信内置代码**,直接使用 +Node 的 `child_process` / `fs` / `https`:因此它实际会 spawn 的 `gh` / `winget` / +`powershell` / `curl` / `unzip` / `tar` / `taskkill` 等二进制,以及写入 +`~/.bitfun/bitfun-loopx/**` 的行为,不需要(也不会)出现在上述 allowlist 中。 +这是内置应用的特权模型;`node.enabled = true` 的应用本来也无法上架市场。 + +## 平台支持 + +- loopx 获取三平台通用,优先级:① 安装包内置的编译二进制(见下节「loopx 依赖 + 与合规」,宿主经 `BITFUN_RESOURCE_DIR` 传入,无需 Python/git/网络)→ ② 一键拉 + 源码到 `~/.bitfun/bitfun-loopx/vendor/loopx`(`python -m loopx.cli` / + `python3 -m loopx.cli` 直跑)→ ③ pip 安装兜底。 +- gh 获取:Windows 走 winget,失败回退 PowerShell 下载 release zip;macOS 走 + Homebrew,失败回退 curl + unzip 下载 release zip;Linux 无通用包管理器,直接 + curl + tar 下载 release tar.gz 到 `~/.bitfun/bitfun-loopx/bin/gh`。 +- `gh auth login` 交互窗口:Windows `cmd start`;macOS Terminal(osascript); + Linux 依次尝试 `x-terminal-emulator` / `gnome-terminal` / `konsole` / `xterm`, + 全部缺失时返回明确的手动指引,不做静默降级。 + +## 设计假设 + +- 单实例桌面使用:心跳与 run-once 注册表都是进程内状态,同时开两个 BitFun + 窗口打开本应用会双心跳、可能重复启动 turn。远程控制(手机/web 遥控)下的 + 审批弹窗触达亦未验证。v1 明确接受该假设。 +- 每轮 turn 由宿主 Agent 执行(`app.agent.run`),loopx 只负责心跳/配额/计划 + 与 todo 状态;本应用不在本地托管任何外部 CLI 宿主(codex 等)。 +- v1 不设 turn 预算/成本上限:心跳节奏由 loopx 配额控制,宿主 Agent 消耗的 + 模型额度由用户在 BitFun 侧自行管理。 + +## loopx 依赖与合规 + +- **内置编译二进制(随安装包分发)**:打包流程(`scripts/desktop-tauri-build.mjs`, + 即 `pnpm run desktop:build*` 的 bundle 路径)会先执行 `scripts/build-loopx.mjs`: + 构建期拉取 pin `v0.2.13` 的 loopx 源码并用 PyInstaller 编译单文件二进制,随 + tauri `bundle.resources` 作为 sidecar 分发(`resources/loopx/`)。桌面宿主把 + 资源目录经 `BITFUN_RESOURCE_DIR` 传给 MiniApp worker,探测时内置二进制优先, + 用户机器零依赖。`--no-bundle` 的 dev 构建不触发该步骤,走 vendor/pip 兜底。 + 生成的 `resources/loopx/` 目录在 `.gitignore` 中,二进制不进仓库;`manifest.json` + 记录版本、commit、内容哈希与构建工具链。 +- **MIT 再分发义务**:loopx 为 MIT(Copyright (c) 2026 LoopX contributors), + 允许以二进制形式再分发,义务为保留许可证与版权声明——`resources/loopx/` 随包 + 携带上游 `LICENSE` 与 `TRADEMARKS.md`,`THIRD_PARTY_NOTICES.md` 已收录 loopx + 条目。名称按 loopx [TRADEMARKS.md](https://github.com/huangruiteng/loopx/blob/main/TRADEMARKS.md) + 描述性使用,本应用是第三方集成,非 LoopX 官方出品。 +- **运行期兜底**:内置二进制缺失/损坏时回退 vendor 源码(`~/.bitfun/bitfun-loopx/vendor/loopx`) + 或 pip 安装;pin 保持一致(`LOOPX_VENDOR_REF` = `v0.2.13`),升级必须伴随 + loopx CLI JSON 契约的显式适配。 +- 本应用自身的 GitHub 凭据只存于本机应用存储(gh CLI 或粘贴的 PAT),不写入 git config。 + +## loopx 依赖升级 + +loopx 不随包分发源码,只 pin 一个经过验证的版本。**不要自动追新**:只有在确实 +需要新版能力/修复时才升级。 + +1. **读 release notes / CHANGELOG**,重点确认 CLI 的 `--format json` 输出契约 + (`quota should-run` / `heartbeat-prompt` / `todo` / `status` / `bootstrap` + 等被 worker 解析的结构)没有破坏性变更; +2. **改 pin**:`worker.js` 的 `LOOPX_VENDOR_REF`(sidecar 构建 `scripts/build-loopx.mjs` + 与 vendor 兜底共用同一个 pin); +3. **本机冒烟**:删除 `~/.bitfun/bitfun-loopx/vendor/loopx` 后走一次「拉取 loopx + 源码」重 pin,再跑一个 issue 全流程(intake → 心跳 → turn → todo 审批 → + PR 发布)验证 JSON 契约; +4. **回滚**:出问题只需把 `LOOPX_VENDOR_REF` 改回旧 tag;vendor 目录在下次 + ensureVendor 自动重新 pin,用户无需手动修复; +5. **随发布提交**:pin 变更与 `version` bump 一起进发布版(发布版自带的 + sidecar 二进制由打包构建重新编译)。 + +**升级经验(首批开发踩坑记录)**: + +- loopx 示例命令里的 `${LOOPX_TURN:?}` 是 bash 语法,Windows 的 PowerShell + 下会原样报错——agent 提示词里明确"这是可选参数,直接省略"; +- 中文 Windows 下 Python CLI 的 stdout 默认 GBK,会破坏 `JSON.parse`——worker + 统一注入 `PYTHONUTF8=1` + `PYTHONIOENCODING=utf-8`; +- `python -m loopx` 不存在(loopx 没有 `__main__.py`),模块形式必须指向 + `loopx.cli`; +- 首次运行时 `quota` 的 public-boundary 扫描以 loopx 源码目录为根,冷缓存下 + 单次扫描可超一分钟——控制台把扫描根指向专用的空目录保证心跳节奏。 diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json @@ -0,0 +1 @@ +[] diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html new file mode 100644 index 0000000000..e740fbe858 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html @@ -0,0 +1,184 @@ + + + + + + bitfun-loopx + + + + + +
+ + +
+
+
+
+
0
+
+
+
+
+
+
+
+ +
+ +
+ + 点选「进行中」的条目查看任务详情与实时日志 +
+
+
+
+ +
+ + +
+
+
+ + +
+

+

+ + + +
+ + +
+
+
+ + +
+

确认这项操作?

+
+ +
+ + + +
+
+
+ + +
+

GitHub Token 设置

+

用于 fork 仓库、推送分支、创建 PR…

+
当前状态:
+ +
+
方式一(推荐):用 GitHub CLI 登录
+

点击下方按钮自动安装 GitHub CLI 并弹出浏览器完成登录,无需手动创建 Token(网络受限时会自动使用系统代理)。

+ + + +
方式二:使用已有的 Token
+

前往 GitHub 创建 Fine-grained Token(需要 Contents 与 Pull requests 的读写权限):

+

github.com/settings/personal-access-tokens/new

+ +
+ +
+ + + +
+
+
+ + +
+

+

+
+ + +
+
+
+ + +

JSON

+
+
+ + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json new file mode 100644 index 0000000000..517bbd9741 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json @@ -0,0 +1,46 @@ +{ + "id": "builtin-bitfun-loopx", + "name": "bitfun-loopx", + "description": "bitfun-loopx:粘贴 GitHub Issue 链接,由 BitFun 宿主 Agent 驱动本机 LoopX 引擎持续修复,心跳调度、人工审批、中途插话。", + "icon": "rocket", + "category": "developer", + "tags": ["loopx", "agent", "scheduler", "monitor"], + "version": 1, + "created_at": 0, + "updated_at": 0, + "permissions": { + "fs": { + "read": ["{appdata}", "{user-selected}", "{home}"], + "write": ["{appdata}"] + }, + "shell": { "allow": ["loopx", "python", "py", "git"] }, + "net": { "allow": ["api.github.com", "github.com"] }, + "notifications": { "system": true }, + "agent": { "enabled": true }, + "ai": { "enabled": true }, + "host": { + "reveal_in_folder": true, + "open_external": true + }, + "node": { + "enabled": true, + "max_memory_mb": 256, + "timeout_ms": 1200000 + } + }, + "ai_context": null, + "i18n": { + "locales": { + "zh-CN": { + "name": "bitfun-loopx", + "description": "bitfun-loopx:粘贴 GitHub Issue 链接,由 BitFun 宿主 Agent 驱动本机 LoopX 引擎持续修复,心跳调度、人工审批、中途插话。", + "tags": ["loopx", "智能体", "调度", "监控"] + }, + "en-US": { + "name": "bitfun-loopx", + "description": "bitfun-loopx: paste a GitHub issue link and the BitFun host agent drives the local LoopX engine to fix it, with heartbeat scheduling, human approval gates and mid-task guidance.", + "tags": ["loopx", "agent", "scheduler", "monitor"] + } + } + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css new file mode 100644 index 0000000000..b9f8ea59ec --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css @@ -0,0 +1,512 @@ +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; width: 100%; height: 100%; } +[hidden] { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + +:root { + --lx-bg: var(--bitfun-bg, #111214); + --lx-surface: var(--bitfun-bg-secondary, #191a1d); + --lx-surface-muted: var(--bitfun-bg-tertiary, #151619); + --lx-elevated: var(--bitfun-bg-elevated, var(--lx-surface)); + --lx-surface-2: var(--bitfun-element-bg, rgba(255,255,255,.055)); + --lx-hover: var(--bitfun-element-hover, rgba(255,255,255,.09)); + --lx-border: var(--bitfun-border-subtle, rgba(255,255,255,.09)); + --lx-border-strong: var(--bitfun-border, rgba(255,255,255,.16)); + --lx-text: var(--bitfun-text, #ececef); + --lx-text-soft: var(--bitfun-text-secondary, #b6b7bc); + --lx-text-mute: var(--bitfun-text-muted, #7d7f87); + --lx-accent: var(--bitfun-accent, #5b8def); + --lx-accent-hover: var(--bitfun-accent-hover, #477de8); + --lx-ok: var(--bitfun-success, #35b779); + --lx-warn: var(--bitfun-warning, #e7a83c); + --lx-error: var(--bitfun-error, #e45b63); + --lx-review: var(--lx-warn); + --lx-radius: var(--bitfun-radius, 8px); + --lx-radius-lg: var(--bitfun-radius-lg, 12px); + --lx-mono: var(--bitfun-font-mono, ui-monospace, "Cascadia Mono", Consolas, monospace); + --lx-sans: var(--bitfun-font-sans, -apple-system, "Segoe UI", system-ui, sans-serif); +} + +body { background: var(--lx-bg); color: var(--lx-text); font: 13px/1.45 var(--lx-sans); overflow: hidden; } +button, input, select { font: inherit; } +.lx { display: flex; flex-direction: column; height: 100vh; min-width: 0; } + +.btn:focus-visible, .goal:focus-visible, .board-more__chip:focus-visible { + outline: 2px solid var(--lx-accent); outline-offset: 1px; +} +.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lx-text-mute); flex: 0 0 auto; } +/* Per-card status chips answer "is this task running normally?" at a glance — + the global header heartbeat readout is gone. */ +.goal-status { + flex: 0 0 auto; display: inline-flex; align-items: center; white-space: nowrap; + padding: 1px 7px; border-radius: 9px; font-size: 10px; line-height: 1.7; +} +.goal-status--live { color: var(--lx-ok); background: color-mix(in srgb, var(--lx-ok) 14%, transparent); } +.goal-status--live::before { content: ''; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; background: currentColor; } +.goal-status--ok { color: var(--lx-ok); background: color-mix(in srgb, var(--lx-ok) 10%, transparent); } +.goal-status--err { color: var(--lx-error); background: color-mix(in srgb, var(--lx-error) 12%, transparent); } +.goal-status--muted { color: var(--lx-text-mute); background: color-mix(in srgb, var(--lx-text-mute) 10%, transparent); } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 30px; + max-width: 280px; padding: 4px 10px; border: 1px solid var(--lx-border); border-radius: var(--lx-radius); + background: transparent; color: var(--lx-text-soft); font-size: 12px; cursor: pointer; + overflow: hidden; white-space: nowrap; +} +.btn:hover { border-color: var(--lx-border-strong); background: var(--lx-hover); color: var(--lx-text); } +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn--icon { width: 30px; padding: 4px; } +.btn--primary { border-color: var(--lx-accent); background: var(--lx-accent); color: white; } +.btn--primary:hover { border-color: var(--lx-accent-hover); background: var(--lx-accent-hover); color: white; } +.btn--danger { border-color: var(--lx-border); background: transparent; color: var(--lx-text-mute); } +.btn--danger:hover { + border-color: color-mix(in srgb, var(--lx-error) 45%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-error) 8%, transparent); color: var(--lx-error); +} + +.banner { display: flex; align-items: flex-start; gap: 12px; margin: 8px 12px 0; padding: 9px 11px; border: 1px solid var(--lx-border-strong); border-radius: var(--lx-radius); } +.banner--warn { border-color: color-mix(in srgb, var(--lx-warn) 45%, transparent); background: color-mix(in srgb, var(--lx-warn) 8%, transparent); } +.banner__body { display: flex; flex: 1; flex-direction: column; gap: 3px; font-size: 12px; } +.prereq-hint { display: block; margin-top: 2px; color: var(--lx-text-mute); font-size: 12px; white-space: pre-line; } +.probe-detail { margin: 4px 0 0; padding: 6px 8px; max-height: 100px; overflow: auto; background: var(--lx-bg); color: var(--lx-text-mute); font: 11px/1.4 var(--lx-mono); white-space: pre-wrap; } + +.composer { + /* The 72px right inset keeps the send button clear of the host's floating + mini-chat bubble (fixed at right:20px, 42px wide); the box itself is + centered symmetrically within the remaining space. */ + flex: 0 0 auto; padding: 8px 72px 12px 14px; background: var(--lx-bg); +} +.composer__box { + width: min(960px, 100%); min-height: 64px; margin: 0 auto; overflow: hidden; + border: 1px solid var(--lx-border); border-radius: var(--lx-radius-lg); background: var(--lx-elevated); + box-shadow: 0 2px 8px color-mix(in srgb, var(--lx-text) 5%, transparent); + transition: min-height 180ms ease; +} +.composer__box:focus-within { border-color: color-mix(in srgb, var(--lx-accent) 68%, var(--lx-border)); } +#task-input { + display: block; width: 100%; min-height: 40px; max-height: 180px; resize: none; + padding: 12px 18px 5px; border: 0; outline: 0; background: transparent; color: var(--lx-text); + font: 14px/1.5 var(--lx-sans); letter-spacing: 0; + transition: min-height 180ms ease; +} +#task-input::placeholder { color: var(--lx-text-mute); } +.composer__footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; padding: 3px 9px 9px 17px; } +.composer__context { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--lx-text-mute); font-size: 11px; } +.composer__kind { flex: 0 0 auto; padding: 2px 7px; border: 1px solid var(--lx-border); border-radius: 9px; color: var(--lx-text-soft); } +.composer__model, +.composer__target { + display: inline-flex; align-items: center; gap: 5px; flex: 0 1 auto; min-width: 0; + padding: 2px 7px; border: 1px solid var(--lx-border); border-radius: 10px; color: var(--lx-text-mute); + cursor: pointer; +} +.composer__model:hover, +.composer__target:hover { border-color: var(--lx-border-strong); } +.composer__model select { + min-width: 0; max-width: 230px; padding: 0; border: 0; outline: 0; background: transparent; + color: var(--lx-text-soft); font-size: 11px; cursor: pointer; +} +/* The goal picker is a custom popover (each option carries its own delete + button), so these rules replace the old select-based chip. */ +.composer__target { position: relative; } +.composer-target__trigger { + display: inline-flex; align-items: center; gap: 5px; min-width: 96px; max-width: 200px; + padding: 0; border: 0; outline: 0; background: transparent; + color: var(--lx-text-soft); font-size: 11px; cursor: pointer; text-align: left; +} +.composer-target__trigger #composer-target-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.composer-target__caret { flex: 0 0 auto; color: var(--lx-text-mute); } +.composer-target__menu { + position: absolute; bottom: calc(100% + 8px); left: -1px; z-index: 30; + min-width: 280px; max-width: 380px; max-height: 280px; overflow-y: auto; + padding: 4px; border: 1px solid var(--lx-border-strong); border-radius: 10px; + background: var(--lx-elevated); + box-shadow: 0 8px 24px color-mix(in srgb, var(--lx-text) 16%, transparent); +} +.composer-target__item { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + width: 100%; padding: 6px 8px; border: 0; border-radius: 7px; + background: transparent; color: var(--lx-text-soft); font-size: 11.5px; + cursor: pointer; text-align: left; +} +.composer-target__item:hover { background: var(--lx-hover); } +.composer-target__item.is-selected { background: var(--lx-surface-2); color: var(--lx-accent); } +.composer-target__item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.composer-target__item-close { + flex: 0 0 auto; display: grid; place-items: center; width: 17px; height: 17px; + border-radius: 50%; color: var(--lx-text-mute); font-size: 13px; line-height: 1; +} +.composer-target__item-close:hover { background: color-mix(in srgb, var(--lx-error) 20%, transparent); color: var(--lx-error); } +/* A picked goal flips the chip to accent: the next input guides that goal. */ +.composer__target--picked { border-color: var(--lx-accent); } +.composer__target--picked .composer-target__trigger { color: var(--lx-accent); } +.composer__submit { + display: grid; place-items: center; flex: 0 0 auto; width: 38px; height: 38px; padding: 0; + border: 0; border-radius: 50%; background: var(--lx-accent); color: #fff; cursor: pointer; +} +.composer__submit:hover { background: var(--lx-accent-hover); } +.composer__submit:disabled { opacity: .42; cursor: not-allowed; } +.composer__submit:focus-visible { outline: 2px solid var(--lx-accent); outline-offset: 2px; } +.composer__feedback { width: min(960px, 100%); margin: 0 auto 6px; padding: 0 4px; color: var(--lx-text-mute); font-size: 11px; } +.composer__feedback--error { color: var(--lx-error); } +.composer__feedback--ok { color: var(--lx-ok); } + +.layout { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.workspace { + flex: 1; min-height: 0; margin: 8px 14px 0; + display: flex; gap: 0; overflow: hidden; +} +/* Column dividers are draggable handles: the visible line is the handle's + center stripe, and grabbing it resizes the neighbouring column. */ +.resize-handle { + flex: 0 0 8px; position: relative; z-index: 5; cursor: col-resize; + background: transparent; touch-action: none; +} +.resize-handle::after { + content: ''; position: absolute; top: 0; bottom: 0; left: 3px; width: 2px; + border-radius: 2px; background: var(--lx-border); + transition: background 120ms ease, width 120ms ease; +} +.resize-handle:hover::after, .resize-handle.is-dragging::after { + background: var(--lx-accent); width: 3px; left: 2.5px; +} +/* 等你处理 stands alone: one narrow decision column in its own frame. */ +.review-zone { + flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; + overflow: hidden; border: 1px solid var(--lx-border); + border-radius: var(--lx-radius-lg); background: var(--lx-surface); +} +/* The run unit: the 进行中 directory rail + the selected task's live log, + visibly ONE frame. */ +.run-frame { + flex: 1; min-width: 0; display: flex; overflow: hidden; + border: 1px solid var(--lx-border); border-radius: var(--lx-radius-lg); background: var(--lx-surface); +} +.run-rail { + flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; + background: var(--lx-surface); +} +.run-rail__list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px; } +.zone-head { + display: flex; flex-direction: column; gap: 3px; padding: 12px; + border-bottom: 1px solid var(--lx-border); font-size: 12px; font-weight: 600; +} +.zone-head-row { display: flex; align-items: center; gap: 7px; } +.zone-sub { font-size: 10.5px; font-weight: 400; color: var(--lx-text-mute); } +.zone-count { margin-left: auto; color: var(--lx-text-mute); font: 11px/1 var(--lx-mono); } +.zone-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 10px; } +.zone-empty { padding: 14px 8px; color: var(--lx-text-mute); font-size: 11.5px; text-align: center; } +.zone-empty--loading { opacity: .72; } +/* Directory rows in the 进行中 rail. */ +.run-item { + display: flex; align-items: flex-start; gap: 8px; width: 100%; padding: 8px 9px; + border: 1px solid transparent; border-radius: var(--lx-radius); background: transparent; color: var(--lx-text); + text-align: left; cursor: pointer; +} +.run-item:hover { border-color: var(--lx-border); background: var(--lx-hover); } +.run-item.is-selected { + border-color: color-mix(in srgb, var(--lx-accent) 34%, var(--lx-border)); + background: var(--lx-surface-2); box-shadow: inset 2px 0 0 var(--lx-accent); +} +.run-item__meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.run-item__id { font: 11px/1.3 var(--lx-mono); color: var(--lx-text-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.run-item__text { color: var(--lx-text-mute); font-size: 11px; line-height: 1.4; display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; } +.run-item .dot { margin-top: 4px; } +.run-item .goal-status { margin-top: 3px; } +.run-item--pending { border: 1px dashed var(--lx-border); } +.run-item--pending .goal__activity-text { flex: 0 0 auto; max-width: 42%; color: var(--lx-ok); font-size: 10px; line-height: 1.35; text-align: right; } +/* Parked (aborted/errored) tasks stay visible in the rail, dimmed. */ +.run-rail__divider { margin: 6px 2px 2px; padding: 6px 7px 2px; border-top: 1px dashed var(--lx-border); color: var(--lx-text-mute); font-size: 10px; text-transform: uppercase; } +.run-item--parked { opacity: .62; } +.run-item--parked:hover { opacity: 1; } +/* Master-detail: the selected goal's info + live activity stream takes the + remaining width beside the directory rail. */ +.detail-panel { + position: relative; + flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; + min-height: 0; border-left: 1px solid var(--lx-border); + background: var(--lx-elevated); +} +/* One-click back to the log tail: appears once the reader scrolls up. */ +.log-bottom { + position: absolute; right: 18px; bottom: 14px; z-index: 6; + display: grid; place-items: center; width: 30px; height: 30px; padding: 0; + border: 1px solid var(--lx-border); border-radius: 50%; + background: var(--lx-surface-2); color: var(--lx-text-soft); cursor: pointer; + box-shadow: 0 2px 8px color-mix(in srgb, var(--lx-text) 12%, transparent); +} +.log-bottom:hover { border-color: var(--lx-accent); color: var(--lx-accent); } +.detail-empty { + flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 10px; + color: var(--lx-text-mute); font-size: 12px; +} +/* Structural coupling: whenever the detail panel is visible the empty hint + is hidden — no JS timing can leave both in the flex row at once. */ +.run-frame:has(#goal-detail-panel:not([hidden])) #detail-empty { display: none; } +.detail-panel__header { + display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; + padding: 13px 14px 11px; border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); flex: 0 0 auto; +} +.detail-panel__header h2 { margin: 2px 0 0; font: 600 15px/1.35 var(--lx-sans); overflow-wrap: anywhere; } +.detail-panel__timer { display: inline-block; margin-top: 5px; color: var(--lx-ok); font: 11px/1.3 var(--lx-mono); } +.detail-panel__body { flex: 1; min-height: 0; overflow-y: auto; } +.goal.is-selected { + border-color: color-mix(in srgb, var(--lx-accent) 52%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-accent) 5%, var(--lx-elevated)); + box-shadow: inset 2px 0 0 var(--lx-accent); +} +.dot--backlog { background: var(--lx-text-mute); } +.dot--active { background: var(--lx-ok); } +.dot--review { background: var(--lx-review); } +.dot--done { background: var(--lx-accent); } +.dot--paused { background: var(--lx-text-mute); } +.dot--error { background: var(--lx-error); } +.dot--archived { background: var(--lx-accent); opacity: .45; } + +.goal { + display: flex; flex-direction: column; gap: 8px; width: 100%; min-height: 94px; padding: 11px; + border: 1px solid var(--lx-border); border-radius: var(--lx-radius); background: var(--lx-elevated); + color: var(--lx-text); text-align: left; cursor: pointer; + box-shadow: none; +} +.goal:hover { + border-color: var(--lx-border-strong); background: var(--lx-elevated); + box-shadow: 0 2px 6px color-mix(in srgb, var(--lx-text) 7%, transparent); +} +.goal__head { display: flex; align-items: center; gap: 7px; min-width: 0; } +.goal__id { min-width: 0; overflow: hidden; color: var(--lx-text-mute); font: 10.5px/1.3 var(--lx-mono); text-overflow: ellipsis; white-space: nowrap; } +.goal__arrow { margin-left: auto; color: var(--lx-text-mute); } +.goal__reason { display: -webkit-box; overflow: hidden; color: var(--lx-text); font-size: 13px; font-weight: 600; line-height: 1.42; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; } +.goal__section { display: flex; flex-direction: column; gap: 3px; margin-top: 7px; padding-top: 6px; border-top: 1px dashed var(--lx-border); } +.goal__section-label { color: var(--lx-text-mute); font-size: 10px; font-weight: 700; letter-spacing: .05em; } +.goal__section--decision { border-top-color: color-mix(in srgb, var(--lx-review) 45%, var(--lx-border)); } +.goal__section--decision .goal__section-label { color: var(--lx-review); } +.goal__issues { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 5px; margin-top: 5px; } +.goal__issues-head { margin-right: 2px; color: var(--lx-text-mute); font: 600 10.5px/1.3 var(--lx-mono); } +.goal__issues--rail { margin-top: 4px; gap: 2px 4px; } +.issue-chip { display: inline-block; padding: 1px 6px; border: 1px solid var(--lx-border-strong); border-radius: 999px; color: var(--lx-text-mute); background: var(--lx-surface-2); font: 600 10.5px/1.5 var(--lx-mono); text-decoration: none; } +.issue-chip:hover { border-color: var(--lx-accent); color: var(--lx-accent); } +.issue-chip--done { border-color: color-mix(in srgb, var(--lx-ok) 55%, var(--lx-border)); color: var(--lx-ok); background: color-mix(in srgb, var(--lx-ok) 10%, transparent); } +.issue-chip--open { border-color: color-mix(in srgb, var(--lx-accent) 55%, var(--lx-border)); color: var(--lx-accent); background: color-mix(in srgb, var(--lx-accent) 10%, transparent); } +.issue-chip--blocked { border-color: color-mix(in srgb, var(--lx-error) 55%, var(--lx-border)); color: var(--lx-error); background: color-mix(in srgb, var(--lx-error) 10%, transparent); } +.issue-chip--deferred { border-color: var(--lx-border-strong); color: var(--lx-text-mute); opacity: .75; } +.issue-chip--pending { border-color: var(--lx-border); color: var(--lx-text-mute); background: transparent; } +.issue-chip--more { cursor: default; opacity: .8; } +.goal__activity { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--lx-text-mute); font-size: 10.5px; } +.goal__activity-dot { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: var(--lx-text-mute); } +.goal__activity--live .goal__activity-dot { background: var(--lx-ok); animation: activity-pulse 1.4s ease-in-out infinite; } +.goal__activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@keyframes activity-pulse { 50% { opacity: .35; transform: scale(.78); } } +.goal__meta { display: flex; align-items: center; gap: 6px; min-width: 0; margin-top: auto; color: var(--lx-text-mute); font-size: 10.5px; line-height: 1.35; } +.goal__meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.goal__meta-sep { color: var(--lx-border-strong); } +.goal--terminal { min-height: 76px; background: transparent; box-shadow: none; opacity: .78; } +.goal--terminal .goal__reason { color: var(--lx-text-soft); font-size: 12px; font-weight: 450; } + +/* "More" chips at the bottom of the review zone: terminal/stopped/error + goals and other-host goals; nothing renders when empty. */ +.board-more { display: flex; flex-direction: column; gap: 8px; flex: 0 0 auto; padding: 8px 10px 10px; border-top: 1px solid var(--lx-border); } +.board-more__chips { display: flex; flex-wrap: wrap; gap: 6px; } +.board-more__chip { + display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; + border: 1px solid var(--lx-border); border-radius: 12px; background: transparent; + color: var(--lx-text-soft); font-size: 11px; cursor: pointer; +} +.board-more__chip b { color: var(--lx-text-mute); font: 600 11px/1 var(--lx-mono); } +.board-more__chip:hover, .board-more__chip.is-open { border-color: var(--lx-border-strong); background: var(--lx-hover); color: var(--lx-text); } +.board-more__panel { + display: flex; flex-direction: column; gap: 8px; max-height: 260px; overflow-y: auto; + padding: 8px; border: 1px solid var(--lx-border); border-radius: var(--lx-radius); + background: var(--lx-surface-muted); +} +.board-more__rows { display: flex; flex-direction: column; gap: 6px; } +.board-more__hint { margin: 0 0 2px; color: var(--lx-text-mute); font-size: 11px; } + +/* other-host rows inside the "more" footer */ +.other-tasks__row { display: flex; align-items: center; gap: 10px; padding: 6px 9px; border: 1px solid var(--lx-border); border-radius: var(--lx-radius); background: var(--lx-elevated); } +.other-tasks__meta { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.other-tasks__id { font: 11px/1.3 var(--lx-mono); color: var(--lx-text-soft); } +.other-tasks__text { color: var(--lx-text-mute); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.other-tasks .btn { flex: 0 0 auto; padding: 4px 12px; font-size: 11.5px; } + +.composer__chip { + flex: 0 0 auto; padding: 4px 10px; border: 1px solid var(--lx-border); border-radius: 11px; + background: transparent; color: var(--lx-text-soft); font-size: 11px; cursor: pointer; +} +.composer__chip:hover { border-color: var(--lx-border-strong); background: var(--lx-hover); color: var(--lx-text); } + +/* Gate attention: cards that wait on the user must be impossible to miss. */ +.goal--gated { + border-color: var(--lx-border); border-left: 3px solid var(--lx-review); + background: var(--lx-elevated); +} +.goal--gated.is-selected { + border-color: color-mix(in srgb, var(--lx-accent) 52%, var(--lx-border)); + border-left-color: var(--lx-review); box-shadow: none; +} +.goal__gate-badge { + margin-left: auto; flex: 0 0 auto; padding: 1px 7px; + border: 1px solid color-mix(in srgb, var(--lx-review) 34%, var(--lx-border)); border-radius: 999px; + background: color-mix(in srgb, var(--lx-review) 9%, transparent); + color: var(--lx-review); font-size: 10px; font-weight: 650; white-space: nowrap; +} +/* Attention stays semantic without tinting the entire column. */ +.review-zone .zone-head { color: var(--lx-text); } +.review-zone .zone-count { color: var(--lx-review); font-weight: 700; } + +.btn--approve { border-color: var(--lx-accent); background: var(--lx-accent); color: #fff; } +.btn--approve:hover { border-color: var(--lx-accent-hover); background: var(--lx-accent-hover); color: #fff; } +/* Gate items render as cards, blocking-first: 需要确认 items get an amber + edge, informational (知会) items stay quiet on the background. */ +.gate-items { display: flex; flex-direction: column; gap: 6px; } +.goal__gate-none { color: var(--lx-text-soft); font-size: 11.5px; line-height: 1.5; } +.gate-group-label { color: var(--lx-text-mute); font-size: 10px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } +.gate-card { display: flex; flex-direction: column; gap: 5px; padding: 7px 9px; border: 1px solid var(--lx-border); border-radius: var(--lx-radius); background: var(--lx-surface); } +.gate-card, .gate-card__raw, .gate-card__summary, .gate-card__background { user-select: text; -webkit-user-select: text; } +.gate-card--block { border-color: color-mix(in srgb, var(--lx-review) 55%, var(--lx-border)); background: color-mix(in srgb, var(--lx-review) 4%, var(--lx-surface)); } +.gate-card--info { background: var(--lx-bg); } +.gate-card__kicker { color: var(--lx-text-mute); font: 10px/1.4 var(--lx-mono); } +.gate-card__title { display: flex; align-items: center; gap: 8px; color: var(--lx-text); font-size: 12px; line-height: 1.5; } +.gate-card__label { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } +.gate-card__title .btn { flex: 0 0 auto; white-space: nowrap; } +.gate-card--block .gate-card__label { font-weight: 600; } +/* CSS-drawn status dots instead of glyph icons (emoji glyphs clip in some + fonts and look like broken text in front of the title). */ +.gate-card--block .gate-card__label::before, +.gate-card--info .gate-card__label::before { + content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; + margin-right: 5px; vertical-align: 1px; +} +.gate-card--block .gate-card__label::before { background: var(--lx-review); } +.gate-card--info .gate-card__label::before { background: var(--lx-text-mute); } +.gate-card__raw { color: var(--lx-text-mute); font: 10.5px/1.45 var(--lx-mono); white-space: pre-wrap; word-break: break-all; } +.gate-card__explain { color: var(--lx-text-soft); font-size: 11.5px; line-height: 1.5; } +.gate-card__after { margin-top: 3px; color: var(--lx-ok); font-size: 11px; line-height: 1.5; } +.gate-card__background { color: var(--lx-text-soft); font-size: 11.5px; line-height: 1.5; } +.gate-card__summary { + color: var(--lx-text-soft); font-size: 11.5px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; +} +.gate-card__summary strong { color: var(--lx-text); font-weight: 600; } +.gate-card__summary--loading { color: var(--lx-text-mute); font-style: italic; } +.gate-card__cred { font-size: 11px; line-height: 1.5; } +.gate-card__cred--gh, .gate-card__cred--token { color: var(--lx-ok); } +.gate-card__cred--none { color: var(--lx-warn); } +.approve-text__summary { + color: var(--lx-text-soft); font-size: 12px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; +} +.approve-text__summary strong { color: var(--lx-text); font-weight: 600; } +.gate-card__raw-details { margin-top: 1px; } +.gate-card__raw-details summary { cursor: pointer; color: var(--lx-text-mute); font-size: 10.5px; } +.gate-card__raw-details summary:hover { color: var(--lx-text-soft); } +.gate-card__raw-details .gate-card__raw { margin-top: 4px; } +.goal__conclusion { margin-top: 2px; color: var(--lx-ok); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } +.goal__stage { margin-top: 2px; color: var(--lx-text-mute); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; } +.detail-panel__stage { margin-top: 4px; color: var(--lx-text-mute); font: 11px/1.4 var(--lx-sans); overflow-wrap: anywhere; } +.detail-panel__conclusion { margin-top: 6px; color: var(--lx-ok); font: 600 12.5px/1.4 var(--lx-sans); overflow-wrap: anywhere; } +.approve-text { + display: flex; flex-direction: column; gap: 5px; + margin: 0; padding: 9px 10px; border: 1px solid var(--lx-border); border-left: 3px solid var(--lx-review); + border-radius: var(--lx-radius); background: var(--lx-bg); color: var(--lx-text); font-size: 12.5px; line-height: 1.55; overflow-wrap: anywhere; +} +.approve-text__explain { color: var(--lx-text-soft); font-size: 12px; } + +/* Intake confirmation sheet */ +.intake-selectbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.intake-selectall { display: inline-flex; align-items: center; gap: 7px; color: var(--lx-text-soft); font-size: 12px; cursor: pointer; } +.intake-selectall input { width: 14px; height: 14px; accent-color: var(--lx-accent); } +.intake-count { color: var(--lx-text-mute); font: 11px/1.2 var(--lx-mono); } +.intake-issues { + display: flex; flex-direction: column; max-height: 300px; overflow: auto; + border: 1px solid var(--lx-border); border-radius: 5px; background: var(--lx-bg); +} +.intake-issue { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-bottom: 1px solid var(--lx-border); cursor: pointer; } +.intake-issue:last-child { border-bottom: 0; } +.intake-issue:hover { background: var(--lx-hover); } +.intake-issue input { width: 14px; height: 14px; flex: 0 0 auto; accent-color: var(--lx-accent); } +.intake-issue__num { flex: 0 0 auto; color: var(--lx-text-mute); font: 11px/1.2 var(--lx-mono); } +.intake-issue__title { min-width: 0; overflow: hidden; color: var(--lx-text); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } + +.dlg::backdrop { background: rgba(0,0,0,.38); } +.drawer__kicker { color: var(--lx-text-mute); font: 10.5px/1.2 var(--lx-mono); text-transform: uppercase; } +.activity-stream { + display: flex; flex-direction: column; gap: 6px; max-height: 230px; padding: 9px 10px; + overflow: auto; border: 1px solid var(--lx-border); border-radius: 4px; background: var(--lx-bg); + color: var(--lx-text-soft); font: 11px/1.45 var(--lx-mono); +} +.activity-stream__line { display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 8px; overflow-wrap: anywhere; } +.activity-stream__time { color: var(--lx-text-mute); } +.activity-stream__line--err { color: var(--lx-error); } +/* Agent streamed text reads like chat: one live block, pre-wrap keeps the + model's line breaks. Reasoning is one collapsed block, dimmed. */ +.activity-stream__line--agent .activity-stream__text { + color: var(--lx-text); font: 12px/1.6 var(--lx-sans); + white-space: pre-wrap; word-break: break-word; +} +/* Expandable blocks (sent prompt, reasoning) sit in the content column so + their summary shares one line with the timestamp. */ +.activity-prompt summary { cursor: pointer; color: var(--lx-accent); } +.activity-prompt summary:hover { text-decoration: underline; } +.activity-prompt--think summary { color: var(--lx-text-mute); } +.activity-prompt--think pre { max-height: 200px; } +/* In the detail panel the reasoning block grows fully: ONE scroll context + (the panel body) means new content always follows the bottom instead of + hiding behind a nested scrollbar. */ +.activity-stream--panel .activity-prompt--think pre { max-height: none; } +.activity-prompt pre { + margin: 5px 0 0; padding: 7px 8px; max-height: 240px; overflow: auto; + border: 1px solid var(--lx-border); border-radius: 5px; background: var(--lx-bg); + color: var(--lx-text-soft); font: 10.5px/1.5 var(--lx-mono); + white-space: pre-wrap; word-break: break-all; +} +.badge { display: inline-flex; align-items: center; gap: 5px; width: fit-content; color: var(--lx-text-soft); font-size: 11px; } +.countdown { color: var(--lx-text-mute); font: 11px/1.4 var(--lx-mono); } +.countdown--err, .goal__reason--err, .goal__lastrun--err { color: var(--lx-error); } + +.dlg { min-width: min(360px, 92vw); max-width: min(520px, 94vw); padding: 0; border: 1px solid var(--lx-border-strong); border-radius: var(--lx-radius); background: var(--lx-surface); color: var(--lx-text); } +.dlg--wide { width: min(720px, 86vw); max-width: 94vw; } +.dlg__form { display: flex; flex-direction: column; gap: 12px; padding: 16px; } +.dlg h2 { margin: 0; font-size: 14px; } +.dlg__note { margin: 0; color: var(--lx-text-soft); font-size: 12px; } +.dlg__note--warn { color: var(--lx-warn); border: 1px solid color-mix(in srgb, var(--lx-warn) 45%, transparent); background: color-mix(in srgb, var(--lx-warn) 8%, transparent); border-radius: 6px; padding: 6px 8px; } +.intake-issue__img { flex: 0 0 auto; font-size: 12px; } +.dlg__actions { display: flex; justify-content: flex-end; gap: 7px; } +.field { display: flex; flex-direction: column; gap: 5px; color: var(--lx-text-soft); font-size: 11.5px; } +.field input, .field select { + min-height: 32px; padding: 5px 8px; border: 1px solid var(--lx-border-strong); border-radius: 5px; + background: var(--lx-surface-2); color: var(--lx-text); font: 12px/1.3 var(--lx-mono); +} +.field input:focus-visible, .field select:focus-visible { outline: 2px solid var(--lx-accent); outline-offset: 1px; } +.btn--small { min-height: 26px; padding: 3px 9px; font-size: 11.5px; } +.btn--tiny { min-height: 22px; padding: 2px 8px; font-size: 10.5px; border-radius: 5px; } +/* Lifecycle actions live on the cards/rows themselves. */ +.goal-actions { display: flex; align-items: center; gap: 5px; margin-top: 5px; } +.goal-actions--column { flex-direction: column; align-items: stretch; flex: 0 0 auto; margin-top: 0; } +.run-item { cursor: pointer; } +.run-item__meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +/* The detail panel is the log. */ +.detail-panel__body { display: flex; flex-direction: column; } +.activity-stream--panel { flex: 1; max-height: none; border: 0; padding: 12px 14px; background: var(--lx-elevated); } +.activity-empty { padding: 10px 2px; color: var(--lx-text-mute); font-size: 11.5px; } +.run-argv { max-height: 160px; margin: 0; padding: 9px 10px; overflow: auto; border: 1px solid var(--lx-border); border-radius: 5px; background: var(--lx-bg); font: 11px/1.45 var(--lx-mono); white-space: pre-wrap; word-break: break-all; } +.raw-body { max-height: 54vh; } +.issue-preview { display: flex; flex-direction: column; gap: 5px; max-height: 240px; padding: 9px 10px; overflow: auto; border: 1px solid var(--lx-border); border-radius: 5px; background: var(--lx-bg); font-size: 12px; } +.issue-preview__signal, .issue-preview__line { font: 11px/1.4 var(--lx-mono); } +.issue-preview__line, .issue-preview__todo { color: var(--lx-text-soft); } +.issue-preview__label { margin-top: 3px; color: var(--lx-text-mute); font-size: 10.5px; font-weight: 650; } +.issue-preview__todo { padding: 2px 0 2px 8px; border-left: 2px solid var(--lx-accent); overflow-wrap: anywhere; } + +@media (max-width: 760px) { + .workspace { gap: 0; margin: 8px 8px 0; } + .review-zone { flex-basis: clamp(220px, 36vw, 260px); } + .run-rail { flex-basis: clamp(160px, 28vw, 200px); } + .detail-panel { min-width: 0; } + .detail-panel__header h2 { font-size: 13px; } + .composer { padding: 0 72px 12px 12px; } + .composer__box { border-radius: var(--lx-radius); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; } + .goal__activity--live .goal__activity-dot { animation: none; } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js new file mode 100644 index 0000000000..b9744c5aca --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js @@ -0,0 +1,4932 @@ +// bitfun-loopx — UI + host heartbeat. +// The heartbeat is a single setTimeout chain armed to the earliest per-goal +// due time; each poll's interval is dictated by loopx's scheduler_hint +// (recommended interval, unchanged-poll backoff, max clamp, reset_token). +// Rendering is fingerprint-throttled: unchanged decisions repaint nothing but +// the 1s countdown, and re-renders are deferred while a card input has focus. + +const app = window.app; + +// Boot timeline: timestamps every startup step and the first renders so the +// "extra flash" on import can be attributed to a step (or to the host). +const BOOT_T0 = performance.now(); +let BOOT_RENDER_COUNT = 0; +const bootMs = () => Math.round(performance.now() - BOOT_T0); +const themeProbe = () => String(getComputedStyle(document.documentElement).getPropertyValue('--bitfun-bg')).trim() || '(none)'; + +// Theme gate release (double failsafe for the inline script in index.html — +// some compilers relocate inline scripts, so the main bundle guarantees the +// page can never stay hidden): reveal when the host appearance vars are in, +// when the appearanceChange event arrives, or on a deadline. +const releaseThemeGate = () => { document.documentElement.style.visibility = 'visible'; }; +if (themeProbe() !== '(none)') releaseThemeGate(); +app.onAppearanceChange((payload) => { + dbgUi('theme:applied', `t=${bootMs()}ms mode=${payload && payload.mode}`); + releaseThemeGate(); +}); +setTimeout(releaseThemeGate, 800); + +const I18N = { + 'zh-CN': { + title: 'bitfun-loopx', + refresh: '刷新目标列表', + retry: '重试', + notFoundTitle: '未检测到 loopx CLI', + notFoundHint: '本机未检测到 loopx。可一键拉取 loopx 源码直接运行(无需 pip 安装),或自行 pip 安装。', + vendorLoopxBtn: '拉取 loopx 源码', + vendoringLoopx: '正在拉取…(首次需联网,约 1 分钟)', + vendorDone: '拉取完成', + vendorFailed: '拉取失败', + prereqNeedPython: '缺少 Python 3.11+(loopx 源码运行需要)。安装后点「重试」:python.org/downloads,或 brew install python(macOS)/ winget install Python.Python.3.12(Windows)', + prereqNeedGit: '缺少 git(拉取源码需要)。安装后点「重试」:git-scm.com 或 winget install Git.Git', + prereqUnknown: '无法探测运行环境(需要 Python 3.11+ 与 git)。也可以在自己的终端执行 pip install git+https://github.com/huangruiteng/loopx.git', + issuesProgress: (done, total) => `issues ${done}/${total}`, + issueDone: '已修复', + issueOpen: '进行中', + issueBlocked: '受阻', + issueDeferred: '已搁置', + issuePending: '待处理', + moreIssues: (n) => `+${n}`, + installLoopxBtn: 'pip 安装 loopx', + installingLoopx: '正在安装…(可能需要几分钟)', + installDone: '安装完成', + installFailed: '安装失败', + runOnce: '执行一次', + resumeTask: '继续任务', + resumeTaskHint: '继续该任务:恢复心跳监控与自动执行', + stopTask: '中止任务', + stopTaskHint: '中止该任务:取消本次运行、关闭心跳监控与自动执行', + deleteTask: '删除任务', + deleteTaskHint: '删除该任务:归档运行记录并移除注册表条目(注册表会先备份)', + groupArchived: '已归档', + statusArchived: '已归档', + archivedHint: '该任务的运行记录已被移入归档区(loopx 注册表中已无此任务)。恢复后回到「自动已关」的暂停态,点「继续」即可接着跑。', + restoreTask: '恢复任务', + restoreTaskHint: '把该任务从归档区恢复到看板(重建注册表条目 + 还原运行目录)', + restoreDone: (name) => `已恢复:${name}`, + restoreFailed: '恢复失败', + taskStopped: (id) => `任务 ${id} 已中止:心跳与自动执行已关闭`, + taskResumed: (id) => `任务 ${id} 已继续:心跳与自动执行已重新开启`, + taskDeleted: (id) => `任务 ${id} 已删除`, + deleteTaskFailed: '删除失败', + stopConfirmTitle: '中止这个任务?', + stopConfirmText: (id) => `将取消「${id}」正在进行的运行,并关闭它的心跳监控与自动执行。任务会移入「已停表」,随时可以继续。`, + confirmStop: '确认中止', + deleteConfirmTitle: '删除这个任务?', + deleteConfirmText: (id) => `将归档「${id}」的运行记录并从注册表移除(注册表文件会先备份)。看板将不再显示该任务。`, + confirmDelete: '确认删除', + activityEmpty: '暂无日志', + thinkBlockTitle: '思考过程', + elapsedLabel: (t) => `已用时 ${t}`, + waitingOn: (w) => `等待:${w}`, + waitingAgent: '等待 Agent 执行', + cancel: '取消', + needProject: '执行 run-once 需要先选择项目目录', + needAgent: '该目标没有已注册的 agent,请先填写 agent id', + detected: (v) => `已检测到 loopx:${v}`, + copy: '复制', + close: '关闭', + raw: 'JSON', + groupPaused: '已停表', + groupError: '异常', + colEmpty: '暂无', + loadingGoals: '正在读取任务…', + statusRunning: '执行中', + statusPaused: '已停表', + statusErroring: (n) => `失败 ×${n}`, + statusUnmonitored: '监控已关', + statusManual: '自动已关', + statusAuto: '自动运行', + stageGated: '阶段:等待你的确认', + stageRunning: '阶段:执行中', + stageFixing: (n) => `阶段:修复 #${n}`, + stagePlanning: '阶段:规划中', + skippedDuplicates: (n) => `(已跳过 ${n} 个重复 Issue)`, + runCancelled: '运行已取消', + turnStalled: (m) => `运行僵死:约 ${m} 分钟没有收到 Agent 事件(可能宿主已静默取消本轮),已取消该回合并允许自动重试。`, + streamTrimmed: (n) => `(前文已折叠 ${n} 字符)`, + groupBacklog: '待处理', + groupActive: '进行中', + groupReview: '等你处理', + colSubReview: '阻塞 · 需要你批准后继续', + colSubActive: 'Agent 正在执行', + detailEmptyHint: '点选「进行中」的条目查看任务详情与实时日志', + groupDone: '已完成', + taskPlaceholder: '粘贴 GitHub Issue / 仓库 / Issues 列表链接,可附加修复要求', + taskGuidePlaceholder: (name) => `正在向「${name}」插话:输入指令引导 Agent 继续(不创建新任务)`, + taskGoalUnsupported: '请粘贴 GitHub Issue、PR、仓库首页或 Issues 列表链接(自由目标暂未开放)', + taskUnsupportedPath: (u) => `不支持的 GitHub 链接:${u}。请粘贴 Issue、PR、仓库首页或 Issues 列表链接`, + guidanceNoRunning: '没有正在运行的任务。粘贴 Issue 链接创建新任务,或等任务开始运行后再输入指令。', + guidancePickOne: '有多个任务正在运行:请先在「进行中」点选目标任务,再发送指令。', + guidanceSending: '正在发送指令…', + guidanceSent: (id) => `指令已发送给任务 ${id},Agent 将在下一步读取`, + guidanceLine: (t) => `你:${t}`, + taskCreate: '创建任务', + taskCreating: '正在创建任务…', + taskResolving: '正在识别任务类型…', + taskPendingLabel: '正在创建', + taskStageStarting: '任务已创建,正在启动 Agent', + taskStarted: (id) => `任务 ${id} 已创建并开始执行`, + stageExpand: '正在获取 issue 列表…', + stageBootstrap: '正在创建 bitfun-loopx 任务…', + stageRegister: '正在注册 Agent…', + stagePlan: '正在解析 Issue 修复计划…', + stageTodos: (c, t) => `正在写入 todos ${c}/${t}…`, + stageRefresh: '正在刷新状态…', + intakeTitleIssue: '确认修复这个 Issue', + intakeTitleIssues: (n) => `确认修复 ${n} 个 Issues`, + intakeTitleList: '选择要修复的 Issues', + intakeTitleGoal: '确认新任务', + intakeSummaryList: (repo, n) => `${repo} 现有 ${n} 个 open issues,默认全选。任务会逐个修复选中的 issue。`, + intakeSummaryIssues: (repo) => `以下 issues 将写入同一个任务(${repo}),由 Agent 逐个修复。`, + intakeSummaryGoal: '当前已有任务在进行中——选择新建,或把这段话作为引导写入现有任务。', + intakeSelectAll: '全选', + intakeSelectedCount: (n, m) => `已选 ${n}/${m}`, + intakeModeNew: '新建任务', + intakeConfirmNew: '创建任务', + intakeConfirmIssues: (n) => `开始修复 ${n} 个 Issues`, + intakeConfirmGuide: '写入现有任务', + guideTargetNote: (id) => `将把所选 Issues 作为子任务并入现有任务:${id}`, + composerTargetTitle: '目标:新建任务(完全独立);选择已有任务时,输入的内容会作为人类反馈发送给该任务', + deleteShort: '删除', + deleteGoalNamed: (name) => `删除:${name}`, + resizeHandleHint: '拖拽调整列宽', + logBottomHint: '回到底部', + intakeNoneSelected: '至少选择一个 Issue', + intakeNoIssues: '该仓库没有 open issues', + guideStarted: (id) => `已把所选 Issues 作为子任务并入任务 ${id}`, + gateCount: (n) => `等你处理 ${n} 项`, + gateEmptyHint: '该门禁暂无可直接批准的事项', + gateItemTitle: '待确认事项', + gateItemWithType: (hint) => `待确认事项(${hint})`, + gateGroupBlocking: '需要确认 · 阻塞任务', + gateGroupInfo: '仅知会 · 不阻塞', + gateTypePublish: '发布 / 提交 PR', + gateTypeApprove: '审批', + gateTypeInfo: '知会事项', + gateCardTask: (name) => `任务:${name}`, + gateItemInfoLabel: (hint) => `知会事项 · ${hint}`, + gateExplainWrite: '需要你批准:授予写权限后,Agent 才能实施修改并提交 PR。', + gateExplainDecide: '需要你决定:同意或拒绝这项改动。', + gateExplainPublish: '需要你批准:发布 / 提交 PR。', + gateExplainMerge: '需要你批准:合并操作。', + gateExplainReview: '需要你批准:外部评审请求。', + gateExplainPreload: '该改动为桌面插件增加一个最小 Electron preload 桥。', + gateBackground: '背景:', + gateSummaryLoading: '正在生成中文摘要…', + copyCardHint: '复制这张卡片的内容', + autoApprovedWrite: '已按入库授权自动批准:写权限(离开只读适配器)', + conclusionMerged: '结论:已修复并合并', + conclusionCompleted: '结论:修复已完成', + conclusionNoFollowup: '结论:无需修复(无后续动作)', + conclusionCancelled: '结论:已取消', + conclusionClosed: '结论:已关闭 / 重复', + conclusionFinished: '结论:任务已结束', + approveGate: '批准', + completeTodoBtn: '标记完成', + approveGateTitle: '批准这项操作?', + todoDoneTitle: '标记为已完成?', + approveTitle: '确认这项操作?', + approveNote: '备注(可选,写入 todo 完成记录)', + approveConfirm: '批准并继续', + todoDoneConfirm: '标记完成', + approveGateHint: '这是需要你批准的事项:批准后,任务将按该事项继续执行。', + todoDoneHint: '这是知会/指示类事项:标记完成即可,不需要批准,也不会触发新操作。', + approveDone: '已批准,任务将继续推进', + approveResumed: '批准后任务已恢复自动执行', + todoDoneFeedback: '已标记完成', + githubTokenTitle: 'GitHub Token 设置', + githubTokenExplain: '用于 fork 仓库、推送分支、创建 PR。需要一个 fine-grained Personal Access Token(Repository 读写权限)。Token 仅保存在本机 BitFun 应用存储中,不会上传。如果本机已用 GitHub CLI 登录(gh auth login),发布时会自动复用,无需粘贴 Token。', + githubTokenPlaceholder: 'ghp_ 或 github_pat_ …', + githubTokenSave: '保存并验证', + ghLoginGuideTitle: '方式一(推荐):用 GitHub CLI 登录', + ghLoginGuide: '点击下方按钮自动安装 GitHub CLI 并弹出浏览器完成登录,无需手动创建 Token(网络受限时会自动使用系统代理)。', + ghLoginBtn: '用 GitHub CLI 登录', + tokenGuideTitle: '方式二:使用已有的 Token', + tokenGuide: '前往 GitHub 创建 Fine-grained Token(需要 Contents 与 Pull requests 的读写权限):', + tokenGuideLink: 'github.com/settings/personal-access-tokens/new', + ghLoginDone: (login) => `登录完成:${login}`, + ghLoginFailed: '登录失败', + githubTokenStatus: '当前状态:', + githubTokenSaved: (user) => `Token 有效,已保存(登录名:${user})`, + githubTokenInvalid: 'Token 无效或已过期', + githubTokenMissing: '未配置', + githubTokenSet: '已配置', + approveAndPr: '批准并提交 PR', + approveOnly: '仅批准,不提交 PR', + approveOnlyNote: '用户选择仅批准,不提交 PR', + gateCredGh: '✓ 本机 GitHub 已登录(gh),可直接提交 PR', + gateCredToken: (login) => `✓ 已配置 GitHub Token(${login})`, + gateCredNone: '⚠ 尚未登录 GitHub:提交 PR 前请先完成登录', + gateCredSetup: '配置 GitHub 登录', + gateAfterPublish: '批准后:自动 fork 到你的 GitHub → 推送分支 → 创建 PR(带 [bitfun-loopx] 标记),随后继续剩余 issue', + gateAfterApprove: '批准后:任务继续自动执行', + sectionTarget: '修复目标', + sectionDecision: '需要你决定', + sectionProgress: '当前进度', + sectionResult: '结果', + approvePrTitle: '批准并提交 PR?', + approvePrHint: '批准后控制台将自动:检查/创建你的 fork → 推送修复分支 → 向原仓库创建 PR(标题带 [bitfun-loopx] 标识,可被 GitHub 搜索统计)。', + approvePrNeedToken: '⚠ 尚未配置 GitHub Token,点击「批准」后将先打开 Token 设置。', + publishWorking: '正在发布 PR(首次需要 fork 仓库,可能一两分钟)…', + publishAnalyzing: '正在分析问题原因与解决方案…', + publishDone: (url) => `✅ PR 已提交:${url}`, + publishFailed: 'PR 提交失败', + publishNeedToken: '发布 PR 需要先配置 GitHub Token', + resetLoopxTitle: '清除所有 bitfun-loopx 状态?', + resetLoopxText: '将备份并清除本机全部 loopx 相关状态:所有任务(goal)、todo 与运行历史、全局/项目注册表、控制台的仓库克隆缓存和持久化日志。数据会整体移入控制台数据目录下的 cleared-<时间戳> 备份目录,可手动找回。此操作不可撤销。', + resetLoopxConfirm: '全部清除', + resetLoopxWorking: '正在清除…', + resetLoopxDone: (dir) => `已清除全部 bitfun-loopx 状态(备份保留在 ${dir})`, + resetLoopxFailed: '清除失败', + approveFailed: (e) => `批准失败:${e}`, + notifGateTitle: 'bitfun-loopx 需要你审批', + notifGateBody: (id, block, info) => (info > 0 + ? `${id}:${block} 项待确认、${info} 项仅知会` + : `${id} 有 ${block} 项待确认`), + autoRunNext: '自动执行下一轮', + autoRunDisabled: (id) => `${id} 连续失败,已暂停自动执行`, + activityStarting: '正在启动 Agent…', + activitySentPrompt: (n) => `▶ 已向 Agent 发送指令(${n} 字符,点击展开)`, + activityRunning: (elapsed) => `Agent 正在执行 · 已用时 ${elapsed}`, + activityCommitted: 'bitfun-loopx 已提交本次执行结果', + activityValidationPassed: '独立校验已通过', + activityValidationFailed: '独立校验未通过', + activityStateUpdated: '目标状态已更新', + activityCompleted: '执行已完成', + activityCompletedValidated: '执行已完成 · 校验通过', + activityFailed: '执行失败', + taskGoal: '普通目标', + taskRepository: 'GitHub 仓库', + taskIssue: 'GitHub Issue', + taskIssues: (n) => `${n} 个 Issue`, + taskIssuesList: '整仓 Issues', + taskNeedProject: '请先选择这个任务对应的本地项目目录。', + taskRepoNotFound: (repo) => `GitHub 上找不到仓库:${repo}。请检查链接拼写。`, + taskRepoLookupFailed: '无法访问 GitHub 校验仓库,请稍后重试。', + stageClone: '正在克隆仓库…', + stageClonePercent: (p) => `正在克隆仓库… ${p}%`, + intakeCloneNote: (repo) => `将自动克隆 ${repo} 到小应用数据目录并开始修复(无需本地 checkout)。`, + issueHasImages: '该 issue 描述包含图片(截图),文字可能不足以定位问题', + intakeVisionWarn: (n) => `⚠ 检测到 ${n} 个 issue 的描述包含图片,而当前模型不具备多模态能力:图片里的关键信息可能无法被理解,仅凭文字不一定能确认问题根源。建议先补充文字说明(错误信息、复现步骤等)再创建任务,或改用支持视觉的模型。仍可继续,但修复质量可能受影响。`, + intakeReuseNote: (repo) => `已找到 ${repo} 的本地 checkout,无需重新克隆。`, + intakeWriteNote: '本确认即授权:任务将获得仓库写权限并自动连续执行;仅在需要提 PR/发布时才会再次询问你。', + taskCloneOtherRepo: (expected, actual) => `本地目录绑定的是 ${actual};将把 ${expected} 克隆到独立目录处理。`, + composerModelTitle: '新任务执行模型', + otherTasksTitle: '本机其它 loopx 任务', + otherTasksHint: '非本控制台创建,默认不监控。接管后进入看板并开始心跳轮询。', + adopt: '接管', + adoptedLabel: '已接管', + adoptFailed: (e) => `接管失败:${e}`, + modelAuto: '自动(跟随 BitFun 策略)', + modelPrimaryTag: '主模型', + modelFollowGlobal: '跟随全局默认', + modelChanged: (m) => `执行模型已切换为 ${m}`, + taskNeedAgent: '请先在设置中配置新任务默认 Agent。', + taskCreated: (id) => `任务 ${id} 已创建`, + taskRepoMismatch: (expected, actual) => `链接指向 ${expected},当前项目是 ${actual}。请切换到正确的本地 checkout。`, + taskMultipleRepos: '一个任务只能绑定一个本地仓库,请把不同仓库的链接拆成多个任务。', + taskRepoUnverified: (repo) => `选择的目录不是 ${repo} 的本地 checkout(未找到 GitHub remote)。请先选择正确的仓库目录。`, + taskPartial: (id, n, e) => `任务 ${id} 已创建,但只写入了 ${n} 个 todos:${e}`, + intakeTruncated: (n) => `(仅显示前 ${n} 个,列表未取全)`, + }, + 'en-US': { + title: 'bitfun-loopx', + refresh: 'Refresh goals', + retry: 'Retry', + notFoundTitle: 'loopx CLI not found', + notFoundHint: 'loopx was not detected on this machine. Fetch its source and run it directly (no pip install), or install it yourself with pip.', + vendorLoopxBtn: 'Fetch loopx source', + vendoringLoopx: 'Fetching… (first time needs network, ~1 min)', + vendorDone: 'Fetch complete', + vendorFailed: 'Fetch failed', + prereqNeedPython: 'Python 3.11+ is missing (required to run loopx from source). Install it, then press Retry: python.org/downloads, brew install python (macOS) or winget install Python.Python.3.12 (Windows)', + prereqNeedGit: 'git is missing (required to fetch the source). Install it, then press Retry: git-scm.com or winget install Git.Git', + prereqUnknown: 'Could not probe the environment (needs Python 3.11+ and git). You can also run pip install git+https://github.com/huangruiteng/loopx.git in your own terminal.', + issuesProgress: (done, total) => `issues ${done}/${total}`, + issueDone: 'fixed', + issueOpen: 'open', + issueBlocked: 'blocked', + issueDeferred: 'deferred', + issuePending: 'pending', + moreIssues: (n) => `+${n}`, + installLoopxBtn: 'pip install loopx', + installingLoopx: 'Installing… (may take a few minutes)', + installDone: 'Installation complete', + installFailed: 'Installation failed', + runOnce: 'Run once', + resumeTask: 'Resume task', + resumeTaskHint: 'Resume this task: restore heartbeat and auto-run', + stopTask: 'Abort task', + stopTaskHint: 'Abort this task: cancel the current run, disable heartbeat and auto-run', + deleteTask: 'Delete task', + deleteTaskHint: 'Delete this task: archive its runtime and remove the registry entry (the registry is backed up first)', + groupArchived: 'Archived', + statusArchived: 'Archived', + archivedHint: 'This task was archived (its runtime moved out of the loopx registry). Restoring brings it back paused; press Resume to keep working.', + restoreTask: 'Restore task', + restoreTaskHint: 'Restore this task from the archive back to the board (registry entry + runtime dir)', + restoreDone: (name) => `Restored: ${name}`, + restoreFailed: 'Restore failed', + taskStopped: (id) => `Task ${id} aborted: heartbeat and auto-run disabled`, + taskResumed: (id) => `Task ${id} resumed: heartbeat and auto-run re-enabled`, + taskDeleted: (id) => `Task ${id} deleted`, + deleteTaskFailed: 'Delete failed', + stopConfirmTitle: 'Abort this task?', + stopConfirmText: (id) => `This cancels the running turn of "${id}", switches off its heartbeat monitoring and auto-run. The task moves to "Stopped" and can be resumed anytime.`, + confirmStop: 'Abort it', + deleteConfirmTitle: 'Delete this task?', + deleteConfirmText: (id) => `This archives the runtime records of "${id}" and removes it from the registry (the registry file is backed up first). The task will no longer appear on the board.`, + confirmDelete: 'Delete it', + activityEmpty: 'No log yet', + thinkBlockTitle: 'Reasoning', + elapsedLabel: (t) => `elapsed ${t}`, + waitingOn: (w) => `waiting on: ${w}`, + waitingAgent: 'waiting for the agent', + cancel: 'Cancel', + needProject: 'Run-once requires a project directory', + needAgent: 'This goal has no registered agent — type an agent id first', + detected: (v) => `loopx detected: ${v}`, + copy: 'Copy', + close: 'Close', + raw: 'JSON', + groupPaused: 'Stopped', + groupError: 'Errors', + colEmpty: 'Nothing here', + loadingGoals: 'Loading tasks…', + statusRunning: 'Working', + statusPaused: 'Stopped', + statusErroring: (n) => `${n} fail`, + statusUnmonitored: 'Monitoring off', + statusManual: 'Auto-run off', + statusAuto: 'Auto-run on', + stageGated: 'Stage: waiting for your confirmation', + stageRunning: 'Stage: working', + stageFixing: (n) => `Stage: fixing #${n}`, + stagePlanning: 'Stage: planning', + skippedDuplicates: (n) => ` (${n} duplicate issue${n > 1 ? 's' : ''} skipped)`, + runCancelled: 'run cancelled', + turnStalled: (m) => `Turn stalled: no agent events for about ${m} minutes (the host may have silently cancelled it) — cancelled the turn and allowed an automatic retry.`, + streamTrimmed: (n) => `(earlier content trimmed: ${n} chars)`, + groupBacklog: 'Queued', + groupActive: 'In progress', + groupReview: 'Needs you', + colSubReview: 'Blocking · continues after your approval', + colSubActive: 'The agent is working', + detailEmptyHint: 'Select an entry in "In progress" to see its details and live log', + groupDone: 'Done', + taskPlaceholder: 'Paste a GitHub issue / repository / issues-list link, optionally with fix instructions', + taskGuidePlaceholder: (name) => `Guiding "${name}": type instructions to steer the agent (no new task is created)`, + taskGoalUnsupported: 'Paste a GitHub issue, pull request, repository home, or issues-list link (free-form goals are not open yet)', + taskUnsupportedPath: (u) => `Unsupported GitHub link: ${u}. Paste an issue, a pull request, the repository home, or its issues list.`, + guidanceNoRunning: 'No task is running. Paste an issue link to create one, or wait until a task runs to send instructions.', + guidancePickOne: 'Several tasks are running: select the target in "In progress" first, then send the instruction.', + guidanceSending: 'Sending instruction…', + guidanceSent: (id) => `Instruction sent to task ${id} — the agent reads it on its next step`, + guidanceLine: (t) => `You: ${t}`, + taskCreate: 'Create task', + taskCreating: 'Creating task…', + taskResolving: 'Detecting task type…', + taskPendingLabel: 'Creating', + taskStageStarting: 'Task created, starting the Agent', + taskStarted: (id) => `Task ${id} created and started`, + stageExpand: 'Fetching the issue list…', + stageBootstrap: 'Creating the bitfun-loopx task…', + stageRegister: 'Registering the Agent…', + stagePlan: 'Planning the issue fix…', + stageTodos: (c, t) => `Writing todos ${c}/${t}…`, + stageRefresh: 'Refreshing state…', + intakeTitleIssue: 'Fix this issue?', + intakeTitleIssues: (n) => `Fix ${n} issues?`, + intakeTitleList: 'Select issues to fix', + intakeTitleGoal: 'Confirm new task', + intakeSummaryList: (repo, n) => `${repo} has ${n} open issues — all selected by default. The task fixes the selected issues one by one.`, + intakeSummaryIssues: (repo) => `These issues go into one task (${repo}); the agent fixes them one by one.`, + intakeSummaryGoal: 'Tasks are already running — create a new one, or write this as guidance into an existing task.', + intakeSelectAll: 'Select all', + intakeSelectedCount: (n, m) => `${n}/${m} selected`, + intakeModeNew: 'New task', + intakeConfirmNew: 'Create task', + intakeConfirmIssues: (n) => `Start fixing ${n} issues`, + intakeConfirmGuide: 'Write into existing task', + guideTargetNote: (id) => `The selected issues will be added as subtasks of the existing task: ${id}`, + composerTargetTitle: 'Target: a new independent task, or an existing task that receives your input as feedback', + deleteShort: 'Delete', + deleteGoalNamed: (name) => `Delete: ${name}`, + resizeHandleHint: 'Drag to resize the column', + logBottomHint: 'Back to bottom', + intakeNoneSelected: 'Select at least one issue', + intakeNoIssues: 'This repository has no open issues', + guideStarted: (id) => `Selected issues added as subtasks of task ${id}`, + gateCount: (n) => `${n} item${n > 1 ? 's' : ''} need you`, + gateEmptyHint: 'This gate has no directly approvable item yet', + gateItemTitle: 'Pending confirmation', + gateItemWithType: (hint) => `Pending confirmation (${hint})`, + gateGroupBlocking: 'Needs confirmation · blocking', + gateGroupInfo: 'Informational · not blocking', + gateTypePublish: 'Publish / submit PR', + gateTypeApprove: 'Approval', + gateTypeInfo: 'Informational', + gateCardTask: (name) => `Task: ${name}`, + gateItemInfoLabel: (hint) => `Informational · ${hint}`, + gateExplainWrite: 'Approval needed: grant write access so the agent can implement changes and submit the PR.', + gateExplainDecide: 'Your decision needed: approve or reject this change.', + gateExplainPublish: 'Approval needed: publish / submit the PR.', + gateExplainMerge: 'Approval needed: merge.', + gateExplainReview: 'Approval needed: external review request.', + gateExplainPreload: 'This change adds a minimal Electron preload bridge to the desktop plugin.', + gateBackground: 'Background: ', + gateSummaryLoading: 'Generating the Chinese summary…', + copyCardHint: 'Copy this card', + autoApprovedWrite: 'Auto-approved per intake consent: write access (leaving the read-only adapter)', + conclusionMerged: 'Conclusion: fixed and merged', + conclusionCompleted: 'Conclusion: fix completed', + conclusionNoFollowup: 'Conclusion: no fix needed (no follow-up action)', + conclusionCancelled: 'Conclusion: cancelled', + conclusionClosed: 'Conclusion: closed / duplicate', + conclusionFinished: 'Conclusion: task finished', + approveGate: 'Approve', + completeTodoBtn: 'Mark done', + approveGateTitle: 'Approve this action?', + todoDoneTitle: 'Mark as done?', + approveTitle: 'Confirm this action?', + approveNote: 'Note (optional, recorded on the todo)', + approveConfirm: 'Approve and continue', + todoDoneConfirm: 'Mark done', + approveGateHint: 'This item needs your approval: once approved, the task continues along this action.', + todoDoneHint: 'This is an informational/instructional item: marking it done is enough — no approval and no new action.', + approveDone: 'Approved — the task will continue', + approveResumed: 'auto-run resumed after approval', + todoDoneFeedback: 'Marked done', + githubTokenTitle: 'GitHub token settings', + githubTokenExplain: 'Used to fork the repository, push the branch, and create the PR. Provide a fine-grained Personal Access Token with Repository read/write. The token stays in this machine\'s BitFun app storage only. If the GitHub CLI is already signed in on this machine (gh auth login), publishing reuses it automatically — no token needed.', + githubTokenPlaceholder: 'ghp_ or github_pat_ …', + githubTokenSave: 'Save & verify', + ghLoginGuideTitle: 'Option 1 (recommended): sign in with GitHub CLI', + ghLoginGuide: 'Click the button below to auto-install GitHub CLI and sign in via the browser — no manual token creation (the system proxy is used automatically when the network is restricted).', + ghLoginBtn: 'Sign in with GitHub CLI', + tokenGuideTitle: 'Option 2: use an existing token', + tokenGuide: 'Create a fine-grained token on GitHub (needs Contents and Pull requests read/write):', + tokenGuideLink: 'github.com/settings/personal-access-tokens/new', + ghLoginDone: (login) => `Signed in: ${login}`, + ghLoginFailed: 'Sign-in failed', + githubTokenStatus: 'Status: ', + githubTokenSaved: (user) => `Token valid and saved (login: ${user})`, + githubTokenInvalid: 'Token invalid or expired', + githubTokenMissing: 'Not configured', + githubTokenSet: 'Configured', + approveAndPr: 'Approve & submit PR', + approveOnly: 'Approve only (no PR)', + approveOnlyNote: 'User approved without a PR submission', + gateCredGh: '✓ GitHub signed in on this machine (gh) — the PR can be submitted directly', + gateCredToken: (login) => `✓ GitHub token configured (${login})`, + gateCredNone: '⚠ Not signed in to GitHub yet — sign in before submitting the PR', + gateCredSetup: 'Sign in to GitHub', + gateAfterPublish: 'After approval: forks to your GitHub automatically → pushes the branch → opens the PR (tagged [bitfun-loopx]), then continues with the remaining issues', + gateAfterApprove: 'After approval: the task continues running automatically', + sectionTarget: 'Target', + sectionDecision: 'Your decision', + sectionProgress: 'Progress', + sectionResult: 'Result', + approvePrTitle: 'Approve and submit the PR?', + approvePrHint: 'On approval the console will: check/create your fork → push the fix branch → create a PR against the upstream repository (the title carries the [bitfun-loopx] marker so the tool\'s PRs are searchable).', + approvePrNeedToken: '⚠ No GitHub token configured yet — approving will open the token settings first.', + publishWorking: 'Publishing the PR (first fork may take a minute or two)…', + publishAnalyzing: 'Analyzing the root cause and the solution…', + publishDone: (url) => `✅ PR submitted: ${url}`, + publishFailed: 'PR submission failed', + publishNeedToken: 'A GitHub token is required to publish the PR', + resetLoopxTitle: 'Clear all bitfun-loopx state?', + resetLoopxText: 'This backs up and removes every LoopX-related state on this machine: all goals, todos and run history, global/project registries, the console\'s clone cache and persisted logs. Everything moves into a timestamped backup under the console data directory (cleared-) so it stays recoverable. This cannot be undone.', + resetLoopxConfirm: 'Clear everything', + resetLoopxWorking: 'Clearing…', + resetLoopxDone: (dir) => `All bitfun-loopx state cleared (backup kept at ${dir})`, + resetLoopxFailed: 'Clear failed', + approveFailed: (e) => `Approval failed: ${e}`, + notifGateTitle: 'bitfun-loopx needs your approval', + notifGateBody: (id, block, info) => (info > 0 + ? `${id}: ${block} to confirm, ${info} informational` + : `${id} has ${block} item${block > 1 ? 's' : ''} to confirm`), + autoRunNext: 'Auto-running the next turn', + autoRunDisabled: (id) => `${id} failed repeatedly — auto-run paused`, + activityStarting: 'Starting the Agent…', + activitySentPrompt: (n) => `▶ Instructions sent to the agent (${n} chars, click to expand)`, + activityRunning: (elapsed) => `Agent is working · ${elapsed} elapsed`, + activityCommitted: 'bitfun-loopx committed this run', + activityValidationPassed: 'Independent validation passed', + activityValidationFailed: 'Independent validation failed', + activityStateUpdated: 'Goal state updated', + activityCompleted: 'Run completed', + activityCompletedValidated: 'Run completed · validation passed', + activityFailed: 'Run failed', + taskGoal: 'Goal', + taskRepository: 'GitHub repository', + taskIssue: 'GitHub Issue', + taskIssues: (n) => `${n} Issues`, + taskIssuesList: 'All repo issues', + taskNeedProject: 'Select the local project directory for this task first.', + taskRepoNotFound: (repo) => `GitHub repository not found: ${repo}. Check the link spelling.`, + taskRepoLookupFailed: 'Could not verify the repository on GitHub; try again later.', + stageClone: 'Cloning repository…', + stageClonePercent: (p) => `Cloning repository… ${p}%`, + intakeCloneNote: (repo) => `${repo} will be cloned into the MiniApp data directory (no local checkout needed).`, + issueHasImages: 'This issue embeds images (screenshots) — text alone may not pinpoint the problem', + intakeVisionWarn: (n) => `⚠ ${n} issue(s) embed images, but the current model has no multimodal capability: key information in the screenshots may be unreadable, and text alone may not confirm the root cause. Consider adding a text description (error messages, repro steps) before creating the task, or switch to a vision-capable model. You can still continue, but fix quality may suffer.`, + intakeReuseNote: (repo) => `Found the local checkout of ${repo} — no re-cloning.`, + intakeWriteNote: 'This confirmation grants the task repository write scope and continuous auto-run; you will only be asked again for PR/publish decisions.', + taskCloneOtherRepo: (expected, actual) => `The local checkout is bound to ${actual}; ${expected} will be cloned into its own directory instead.`, + composerModelTitle: 'Execution model for new tasks', + otherTasksTitle: 'Other local loopx goals', + otherTasksHint: 'Created by other loopx hosts; not monitored until adopted.', + adopt: 'Adopt', + adoptedLabel: 'Adopted', + adoptFailed: (e) => `Adopt failed: ${e}`, + modelAuto: 'Auto (follow BitFun policy)', + modelPrimaryTag: 'primary', + modelFollowGlobal: 'Follow global default', + modelChanged: (m) => `Execution model switched to ${m}`, + taskNeedAgent: 'Configure the default Agent for new tasks in Settings first.', + taskCreated: (id) => `Task ${id} created`, + taskRepoMismatch: (expected, actual) => `The link targets ${expected}, but the current project is ${actual}. Select the matching local checkout.`, + taskMultipleRepos: 'One task can bind only one local repository. Split links from different repositories into separate tasks.', + taskRepoUnverified: (repo) => `The selected directory is not a checkout of ${repo} (no GitHub remote found). Pick the repository directory first.`, + taskPartial: (id, n, e) => `Task ${id} was created, but only ${n} todos were written: ${e}`, + intakeTruncated: (n) => `(showing the first ${n} — the list is incomplete)`, + }, +}; + +function t(key, ...args) { + const table = {}; + for (const [loc, entries] of Object.entries(I18N)) table[loc] = entries[key]; + const v = app.t(table, I18N['en-US'][key]); + return typeof v === 'function' ? v(...args) : v; +} + +// ── state ───────────────────────────────────────────────── +const DEFAULT_INTERVAL_MIN = 1; +const ERROR_BACKOFF_CAP_MIN = 30; + +const S = { + config: { + projectDir: null, argvPrefix: null, srcDir: '', agentByGoal: {}, monitorByGoal: {}, + projectByGoal: {}, ownedGoals: {}, defaultAgentId: 'bitfun-agent', autoRunByGoal: {}, + defaultModel: 'auto', modelByGoal: {}, + // GitHub fine-grained PAT (Repository read/write) for the publish flow + // (fork → push branch → create PR). Kept in the local app storage only. + githubToken: '', + githubLogin: '', + // Local GitHub CLI credential probe (null = unknown, probed at boot). + ghAvailable: null, + // Drag-resizable column widths in px (0 = CSS default). + reviewZoneWidth: 0, + railWidth: 0, + // Explicit user stops: stoppedByGoal persists the parked state across + // restarts; autoRunBeforeStop remembers the auto-run setting to restore. + stoppedByGoal: {}, autoRunBeforeStop: {}, + // Host agent session per goal. Persisted (not just in-memory) so a turn + // after an app restart reuses the SAME hidden session: the host restores + // it from disk and the agent continues with its full prior context + // instead of starting from scratch. + agentSessionByGoal: {}, + }, + detect: null, + goals: new Map(), // goalId -> G + bootLoading: true, // initial goals refresh in flight + agentSessionByGoal: new Map(), // goalId -> host agent sessionId (context reuse) + timer: null, + countdownTimer: null, + paused: false, + renderPending: false, + // Selected composer target goal ('' = 新建任务). The picker is a custom + // popover: native selects cannot host per-option delete buttons. + composerTargetId: '', + activeGoalId: null, + intakeDraft: null, + pendingIntake: null, // resolveIntake result awaiting sheet confirmation + moreOpen: new Set(), + logs: [], + // Persisted activity logs (goalId -> {lines}) restored on boot so the + // stream survives console restarts; bounded per goal before each save. + persistedLogs: {}, + // Persisted gate summaries (goalId -> {todoId -> {status, text}}) so the + // three-line Chinese summary is ALREADY on the card when it renders — + // generated once, displayed instantly on every later session. + persistedGateSummaries: {}, +}; + +// Direction C: a goal created by auto-clone binds to its own clone directory; +// goals bound to the user's selected checkout use the global setting. +function goalProjectDir(goalId) { + return S.config.projectByGoal[goalId] || S.config.projectDir || null; +} + +// v3.2: the board only manages goals this console created (bfx- prefix or an +// explicit adoption record). Goals created by other loopx hosts on this +// machine are shown separately and stay unmonitored until adopted. +function isOwnedGoal(goalId) { + if (!goalId) return false; + if (String(goalId).startsWith('bfx-')) return true; + if (S.config.ownedGoals && S.config.ownedGoals[goalId]) return true; + return false; +} + +// All registries the board should aggregate: the selected checkout plus every +// clone directory recorded for created goals. +function projectRegistryDirs() { + const dirs = []; + if (S.config.projectDir) dirs.push(S.config.projectDir); + for (const dir of Object.values(S.config.projectByGoal || {})) { + if (dir && !dirs.includes(dir)) dirs.push(dir); + } + return dirs; +} + +// ── execution model selection ─────────────────────────────── +// Long-running fixes let the user pick the host agent model per goal, with a +// global default in Settings. Values: 'auto' (follow the host policy) or a +// concrete model config id from the host's model list. The abstract +// 'primary'/'fast' slot labels are NOT shown: the host marks its primary +// model (isDefault) in the catalog, and listing slots next to the concrete +// models they resolve to just duplicates one model under several labels. +S.modelCatalog = []; +function modelForGoal(goalId) { + return S.config.modelByGoal[goalId] || S.config.defaultModel || 'auto'; +} + +function fillModelSelect(select, currentValue, includeFollowGlobal) { + select.replaceChildren(); + if (includeFollowGlobal) { + const follow = document.createElement('option'); + follow.value = ''; + follow.textContent = t('modelFollowGlobal'); + select.appendChild(follow); + } + const auto = document.createElement('option'); + auto.value = 'auto'; + auto.textContent = t('modelAuto'); + auto.selected = currentValue === 'auto'; + select.appendChild(auto); + // Legacy slot values ('primary'/'fast') migrate onto the host's primary + // model id so a persisted config never dangles after the label cleanup. + const primaryModel = (S.modelCatalog || []).find((m) => m && m.isDefault); + let effective = currentValue; + if (effective === 'primary' || effective === 'fast') { + effective = primaryModel ? primaryModel.id : 'auto'; + } + for (const model of S.modelCatalog || []) { + if (!model || !model.id) continue; + const option = document.createElement('option'); + option.value = model.id; + const tag = model.isDefault === true ? ` · ${t('modelPrimaryTag')}` : ''; + option.textContent = `${model.name || model.id}${tag}`; + option.selected = effective === model.id; + select.appendChild(option); + } +} + +// The composer carries a compact copy of the model default (right where the +// user submits); Settings keeps the full row. Both stay in sync. +function syncComposerModel() { + fillModelSelect(document.getElementById('composer-model'), S.config.defaultModel || 'auto', false); +} + +// The host model catalog currently exposes only supports_text_chat — no +// vision flag — so capability detection falls back to a name heuristic. +// Unknown models are treated as text-only: the conservative reading the +// image guard needs (screenshots may carry the whole problem). +const VISION_MODEL_HINTS = /vision|multimodal|gpt-4o|o1|o3|gemini|claude|qwen[^ ]*-?vl|glm-?4v|pixtral|llava|internvl|moondream/i; +function modelSupportsVision() { + const catalog = Array.isArray(S.modelCatalog) ? S.modelCatalog : []; + const currentId = String(S.config.defaultModel || 'auto'); + const entry = catalog.find((m) => m && m.id === currentId) + || catalog.find((m) => m && m.isDefault) + || catalog[0]; + if (!entry) return false; + const flags = String( + (entry.capabilities && Array.isArray(entry.capabilities) ? entry.capabilities.join(',') : entry.capabilities) + || entry.capability || '' + ).toLowerCase(); + if (flags && /vision|multimodal|image/.test(flags)) return true; + if (flags && /text_chat/.test(flags)) return false; + const label = `${String(entry.name || '')} ${String(entry.modelName || '')} ${String(entry.id || '')}`; + return VISION_MODEL_HINTS.test(label); +} + +// The composer shows where the next intake lands: a new task (default) or an +// existing goal. The picker is a custom popover so every goal option carries +// its own delete (×) button — native selects cannot host per-option buttons. +function composerTargetValue() { + return S.composerTargetId || ''; +} + +// The input hint must match the mode: paste-a-link for new tasks, guide +// wording when an existing goal is selected (interjections, not intake). +function updateTaskPlaceholder() { + const input = document.getElementById('task-input'); + if (!input) return; + if (S.composerTargetId) { + const g = S.goals.get(S.composerTargetId); + input.placeholder = t('taskGuidePlaceholder', g ? goalDisplayName(g) : S.composerTargetId); + } else { + input.placeholder = t('taskPlaceholder'); + } +} + +function setComposerTarget(id) { + S.composerTargetId = id || ''; + updateTaskPlaceholder(); + const label = document.getElementById('composer-target-label'); + if (label) { + if (S.composerTargetId) { + const g = S.goals.get(S.composerTargetId); + label.textContent = g ? goalDisplayName(g) : S.composerTargetId; + label.title = S.composerTargetId; + } else { + label.textContent = t('intakeModeNew'); + label.title = ''; + } + } + const chip = document.getElementById('composer-target'); + if (chip) chip.classList.toggle('composer__target--picked', Boolean(S.composerTargetId)); +} + +function closeComposerTargetMenu() { + const menu = document.getElementById('composer-target-menu'); + if (menu) menu.hidden = true; +} + +// Refills are cheap and idempotent, so callers (render, refresh, boot) may +// invoke this freely; it never depends on the board render having completed. +function refillComposerTarget() { + const menu = document.getElementById('composer-target-menu'); + if (!menu) return; + try { + const previous = composerTargetValue(); + menu.replaceChildren(); + const optNew = document.createElement('button'); + optNew.type = 'button'; + optNew.className = 'composer-target__item' + (previous ? '' : ' is-selected'); + const newLabel = document.createElement('span'); + newLabel.className = 'composer-target__item-label'; + newLabel.textContent = t('intakeModeNew'); + optNew.appendChild(newLabel); + optNew.onclick = () => { + setComposerTarget(''); + closeComposerTargetMenu(); + }; + menu.appendChild(optNew); + for (const g of S.goals.values()) { + if (isTerminal(g)) continue; + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'composer-target__item' + (g.goalId === previous ? ' is-selected' : ''); + const label = document.createElement('span'); + label.className = 'composer-target__item-label'; + // Status suffix makes same-repo historical siblings distinguishable. + const pending = Array.isArray(g.userTodos) && g.userTodos.length + ? ` · ${t('gateCount', g.userTodos.length)}` + : ''; + label.textContent = `${goalDisplayName(g)} · ${goalStatus(g).text}${pending}`; + label.title = g.goalId; + item.appendChild(label); + const close = document.createElement('span'); + close.className = 'composer-target__item-close'; + close.textContent = '×'; + close.title = `${t('deleteGoalNamed', goalDisplayName(g))}(${g.goalId})`; + close.onclick = (ev) => { + ev.stopPropagation(); + openDeleteConfirm(g); + }; + item.appendChild(close); + item.onclick = () => { + setComposerTarget(g.goalId); + closeComposerTargetMenu(); + }; + menu.appendChild(item); + } + // Keep the pick valid; when its goal vanished (deleted), fall back to new. + if (previous && !S.goals.has(previous)) setComposerTarget(''); + else setComposerTarget(previous); + dbgUi('refillTarget', `opts=${menu.children.length} goals=${S.goals.size} value=${JSON.stringify(composerTargetValue())}`); + } catch (err) { + dbgUi('refillTarget:error', String(err && (err.stack || err.message) || err).slice(0, 300)); + } +} + +function newGoalState(goalId, info) { + const archived = !!info.archived; + return { + goalId, + objective: info.objective || null, + agents: info.agents || [], + agentId: S.config.agentByGoal[goalId] || (info.agents && info.agents[0]) || '', + state: info.state || null, + waitingOn: info.waitingOn ?? null, + // v3.2: only owned goals poll by default; other-host goals stay quiet + // until the user adopts them. An explicit user stop overrides ownership. + // Archived goals never poll or auto-run — they live in the quiet 已归档 + // group until the user restores them explicitly. + monitoring: !archived && (isOwnedGoal(goalId) + ? (S.config.monitorByGoal[goalId] !== false && S.config.stoppedByGoal[goalId] !== true) + : S.config.monitorByGoal[goalId] === true), + userStopped: S.config.stoppedByGoal[goalId] === true, + // loopx's philosophy is auto-run by default: owned goals execute + // automatically unless the user explicitly switched auto-run off. + // (Re-discovered cache goals on a fresh import get a fresh config, so + // defaulting to ON keeps them running instead of vanishing into the + // hidden queued state.) + autoRun: !archived && (isOwnedGoal(goalId) + ? S.config.autoRunByGoal[goalId] !== false + : S.config.autoRunByGoal[goalId] === true), + archived, + archiveDir: info.archiveDir || null, + autoFailCount: 0, + intervalMin: DEFAULT_INTERVAL_MIN, + nextDueAt: 0, + unchangedCount: 0, + errorCount: 0, + lastError: null, + lastResetToken: null, + lastDecisionKey: null, + hint: null, // { base, mult, cap } for the interval-math line + stopped: false, + polling: false, + repollQueued: false, + running: false, + runStartedAt: 0, + lastRun: null, // { exitCode, durationMs, status, ok, cancelled } + last: null, // normalized shouldRun result + userTodos: null, // open user-lane todos (gate approvals), null = not loaded + userTodosAt: 0, + userTodosLoading: false, + // Per-issue tracker for batch goals (one agent todo per issue): the + // board projection { issues:[{url,number,title,status,done}], done, total }. + issues: null, + issuesAt: 0, + issuesLoading: false, + wasGated: false, + // First gate observation of the session adopts silently: pre-existing + // gates must not re-notify when the console opens or a new task starts. + firstGateCheck: true, + // Gate-item helpers: agent-lane todos (issue titles as background) and + // per-item Chinese summaries generated by the host agent. + agentTodos: [], + gateSummaries: (() => { + const stored = S.persistedGateSummaries && S.persistedGateSummaries[goalId]; + const map = new Map(); + if (stored && typeof stored === 'object') { + for (const [todoId, v] of Object.entries(stored)) { + if (v && v.status === 'done' && v.text) { + // Self-heal legacy caches: summaries persisted before the + // parse-don't-filter rule may contain reasoning walls — clean + // them on restore so dirty data never resurfaces on the card. + map.set(todoId, { status: 'done', text: cleanGateSummary(String(v.text)) }); + } + } + } + return map; + })(), + activityLines: [], + currentActivity: '', + }; + // Restore the persisted log so the stream survives console restarts. + const persisted = S.persistedLogs && S.persistedLogs[goalId]; + if (Array.isArray(persisted) && persisted.length) { + state.activityLines = persisted.map((e) => ({ + time: e.time || '', line: String(e.line || ''), isErr: !!e.isErr, + count: e.count || 1, kind: e.kind || null, raw: e.raw || null, + stream: !!e.stream, isTick: !!e.isTick, + })); + // Card activity line = progress, never model prose: prefer the last + // tool/status line over agent/think stream text when restoring, so cached + // self-talk ("我需要用三句话…") never resurfaces on the board. + const last = [...persisted].reverse() + .find((e) => e.line && !e.isTick && e.kind !== 'agent' && e.kind !== 'think') + || [...persisted].reverse().find((e) => e.line && !e.isTick); + if (last) state.currentActivity = activityText(String(last.line)); + } + return state; +} + +// ── log persistence ───────────────────────────────────────── +// The stream is incremental and lives in memory; persist it (debounced, per +// goal, bounded) so a console restart keeps the log. Raw prompt bodies are +// capped before they hit storage. +let logSaveTimer = null; +let logSaveDirty = false; +function scheduleLogSave() { + logSaveDirty = true; + if (logSaveTimer) return; + logSaveTimer = setTimeout(() => { + logSaveTimer = null; + if (!logSaveDirty) return; + logSaveDirty = false; + saveLogs(); + }, 3000); +} +// Gate summaries persist so the three-line summary renders instantly on the +// card in later sessions — the model only pays latency the first time. +let gateSummarySaveTimer = null; +function scheduleGateSummarySave() { + if (gateSummarySaveTimer) return; + gateSummarySaveTimer = setTimeout(async () => { + gateSummarySaveTimer = null; + const store = {}; + for (const g of S.goals.values()) { + if (!g.gateSummaries || !g.gateSummaries.size) continue; + const obj = {}; + for (const [todoId, v] of g.gateSummaries) { + if (v && v.status === 'done' && v.text) obj[todoId] = { status: 'done', text: v.text }; + } + if (Object.keys(obj).length) store[g.goalId] = obj; + } + S.persistedGateSummaries = store; + try { await app.storage.set('gateSummaries', store); } catch (_) {} + }, 2000); +} + +async function saveLogs() { + const logs = {}; + for (const g of S.goals.values()) { + if (!Array.isArray(g.activityLines) || !g.activityLines.length) continue; + // Persistence is a restart-history snapshot, not the raw view: keep the + // window small (120 lines × 1.2KB) so multi-goal boards never round-trip + // megabytes of JSON through the worker every few seconds. + logs[g.goalId] = g.activityLines.slice(-120).map((e) => ({ + time: e.time, line: String(e.line || '').slice(0, 1200), + isErr: !!e.isErr, count: e.count || 1, kind: e.kind || null, + raw: e.raw ? String(e.raw).slice(0, 1200) : null, + stream: !!e.stream, isTick: !!e.isTick, + })); + } + try { await app.storage.set('logs', logs); } catch (_) {} +} + +// ── logging ─────────────────────────────────────────────── +// ── debug trace (UI side) ────────────────────────────────── +// Written to /debug-ui.log through the host fs bridge so host logs +// are not required for diagnosis. +const DEBUG_UI = []; +let DEBUG_UI_BUSY = false; +async function dbgUi(tag, detail) { + const line = `${new Date().toISOString()} ${tag} ${detail || ''}`; + DEBUG_UI.push(line); + if (DEBUG_UI.length > 200) DEBUG_UI.shift(); + if (DEBUG_UI_BUSY || typeof app === 'undefined' || !app.appDataDir || !app.fs) return; + DEBUG_UI_BUSY = true; + try { + await app.fs.writeFile(`${app.appDataDir}/debug-ui.log`, DEBUG_UI.join('\n')); + } catch (_) { + // The trace must never break the flow. + } finally { + DEBUG_UI_BUSY = false; + } +} + +// Uncaught UI errors must land in the diagnostic log: a silent render crash +// is otherwise indistinguishable from a frozen app. Keep the slices short so +// one noisy frame cannot drown the timeline. +window.addEventListener('error', (e) => { + dbgUi('uiError', `${e.message || e.error || 'unknown'} @${(e.filename || '').split('/').pop()}:${e.lineno || '?'}`); +}); +window.addEventListener('unhandledrejection', (e) => { + const r = e && e.reason; + dbgUi('uiRejection', String((r && (r.stack || r.message)) || r).slice(0, 400)); +}); + +// ── logging ─────────────────────────────────────────────── +// Diagnostic trace kept in memory for debugging; the user-facing log surface +// is the per-task activity panel, so there is no global log drawer anymore. +function log(msg, isErr = false) { + const time = new Date().toTimeString().slice(0, 8); + S.logs.push({ time, msg, isErr }); + if (S.logs.length > 500) S.logs.splice(0, S.logs.length - 500); +} + +// ── config persistence ──────────────────────────────────── +async function loadConfig() { + try { + const stored = await app.storage.get('config'); + if (stored && typeof stored === 'object') Object.assign(S.config, stored); + } catch (_) {} + try { + const logs = await app.storage.get('logs'); + if (logs && typeof logs === 'object') S.persistedLogs = logs; + } catch (_) {} + try { + const summaries = await app.storage.get('gateSummaries'); + if (summaries && typeof summaries === 'object') S.persistedGateSummaries = summaries; + } catch (_) {} + // Execution moved to the host agent; drop persisted external-host settings. + delete S.config.host; + delete S.config.codexBin; + delete S.config.hostCommandJson; + delete S.config.validationCommandJson; + delete S.config.timeoutSeconds; + if (!S.config.defaultAgentId) { + S.config.defaultAgentId = Object.values(S.config.agentByGoal || {}).find(Boolean) || 'bitfun-agent'; + } +} +async function saveConfig() { + try { await app.storage.set('config', S.config); } catch (_) {} +} + +// ── heartbeat scheduling ────────────────────────────────── +function rearmTimer() { + if (S.timer) { clearTimeout(S.timer); S.timer = null; } + if (S.paused) return; + let earliest = Infinity; + for (const g of S.goals.values()) { + if (g.monitoring && !g.stopped && !g.polling && g.nextDueAt < earliest) earliest = g.nextDueAt; + } + if (earliest === Infinity) return; + const delay = Math.max(0, earliest - Date.now()); + S.timer = setTimeout(onTimerFire, Math.min(delay, 2147000000)); +} + +function onTimerFire() { + S.timer = null; + const now = Date.now(); + for (const g of S.goals.values()) { + if (g.monitoring && !g.stopped && !g.polling && g.nextDueAt <= now) pollGoal(g); + } + rearmTimer(); +} + +function valueAtPath(obj, path) { + return path.split('.').reduce((c, p) => (c && typeof c === 'object' ? c[p] : undefined), obj); +} + +// Prefer the contract's unchanged_identity_keys over home-grown fields: +// free-text `reason` embeds live quota fractions and would defeat backoff. +function decisionKey(res) { + const keys = res.scheduler?.unchangedIdentityKeys; + if (keys && keys.length && res.raw) { + return keys.map((k) => String(valueAtPath(res.raw, k))).join('|'); + } + return [res.shouldRun, res.state, res.effectiveAction].map(String).join('|'); +} + +function applyPollError(g, message) { + g.errorCount += 1; + g.lastError = message; + g.intervalMin = Math.min(Math.pow(2, g.errorCount), ERROR_BACKOFF_CAP_MIN); + log(`[${g.goalId}] poll failed ×${g.errorCount}: ${message}`, true); + // The panel IS the only log surface now: a dead worker / broken loopx + // must be visible there instead of freezing silently. + recordGoalActivity(g, `⚠ ${message}`, true); +} + +async function pollGoal(g) { + if (g.polling) return; + g.polling = true; + requestRender(); + try { + const res = await app.call('loopx.shouldRun', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + agentId: g.agentId || undefined, + }); + if (res.raw) g.last = res; // keep partial payloads visible (reason, state) + if (res.ok === false || res.error) { + // CLI-level failure (bad exit / no JSON) is an error, not a decision. + applyPollError(g, res.error || res.reason || 'loopx exited non-zero'); + return; + } + g.errorCount = 0; + g.lastError = null; + // shouldRun is authoritative for the gate: a cleared waiting_on (null) + // must un-gate the goal rather than stick to the stale listGoals value. + g.waitingOn = res.waitingOn ?? null; + const sched = res.scheduler || {}; + const recommended = Number(sched.recommendedIntervalMinutes) || DEFAULT_INTERVAL_MIN; + const maxIv = Number(sched.maxIntervalMinutes) || Math.max(recommended, 60); + const backoff = Number(sched.backoffMultiplier) || 2; + g.hint = { base: recommended, mult: backoff, cap: maxIv }; + const token = sched.resetToken || null; + const key = decisionKey(res); + + if (token !== g.lastResetToken) { + // loopx-side goal mutation → reset cadence to the fresh recommendation + g.lastResetToken = token; + g.intervalMin = recommended; + g.unchangedCount = 0; + g.stopped = false; + log(`[${g.goalId}] reset_token changed → interval ${g.intervalMin}m`); + } else if (key !== g.lastDecisionKey) { + g.intervalMin = recommended; + g.unchangedCount = 0; + const reasonBrief = String(res.reason || '').slice(0, 160); + log(`[${g.goalId}] decision changed (${res.state ?? '?'}/${res.shouldRun}) → interval ${g.intervalMin}m${reasonBrief ? ` · ${reasonBrief}` : ''}`); + } else { + g.unchangedCount += 1; + g.intervalMin = Math.min(g.intervalMin * backoff, maxIv); + const limit = sched.unchangedPollLimit; + if (limit != null && g.unchangedCount >= limit && sched.afterLimit === 'stop_tick_loop') { + g.stopped = true; + log(`[${g.goalId}] unchanged ×${g.unchangedCount} ≥ limit → tick loop stopped`); + } + // Steady-state backoff steps stay invisible on the card; logging every + // tick would flood the diagnostic log. + } + g.lastDecisionKey = key; + g.intervalMin = Math.min(Math.max(g.intervalMin, recommended), maxIv); + } catch (err) { + applyPollError(g, String(err.message || err)); + } finally { + g.nextDueAt = Date.now() + g.intervalMin * 60000; + g.polling = false; + // The goal may have been dropped/replaced mid-poll (project switch, + // refreshGoals): an orphaned closure must not notify or launch anything. + if (isLiveGoal(g)) { + syncGateState(g); + requestRender(); + rearmTimer(); + maybeAutoRun(g); + if (g.repollQueued) { + g.repollQueued = false; + pollNow(g); + } + } + } +} + +function pollNow(g, { force = false } = {}) { + if (g.polling) { + // A poll is in flight; queue exactly one follow-up instead of silently + // dropping the request (matters after run-once completes). + g.repollQueued = true; + return; + } + g.nextDueAt = 0; + g.stopped = false; + // force bypasses the visibility pause: a finished run must still get its + // decision poll (which chains the next auto-run turn and fires gate + // notifications) while the window is hidden — the exact moment the batch + // and the "needs your approval" notification matter most. + if (S.paused && !force) return; // due immediately once the heartbeat resumes + pollGoal(g).then(rearmTimer); +} + +// ── user gates (approvals) ──────────────────────────────── +// A gated goal's concrete asks live in its user-lane todos. Load them lazily +// when a goal enters the review group, cache for 60s, and raise attention +// (system notification) exactly on the not-gated → gated edge. Returns true +// when a fetch actually ran (false = skipped: in-flight or fresh cache) so +// callers can chain a re-evaluation without looping. +async function refreshUserTodos(g, force = false) { + if (g.userTodosLoading) return false; + if (!force && g.userTodos && Date.now() - g.userTodosAt < 60000) return false; + g.userTodosLoading = true; + try { + const res = await app.call('loopx.listTodos', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + role: 'user', + status: 'open', + }); + g.userTodos = res.ok ? res.todos : []; + g.userTodosAt = Date.now(); + // The intake sheet already granted write scope (bootstrap + // --write-scope write): a "leave the read-only adapter" gate just re-asks + // for that consent, so complete it automatically and reload — the user + // keeps only real decisions (design choices) and publish/PR approvals. + const writeGates = g.userTodos.filter((td) => + td.task_class === 'user_gate' + && /write access|leave the read-?only adapter|connected-read-only/i.test(String(td.text || td.title || ''))); + for (const todo of writeGates) { + try { + const done = await app.call('loopx.completeTodo', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + todoId: todo.todo_id, + note: '由任务入库确认自动批准(写权限已预授)', + decisionOutcome: 'approve', + }); + if (done.ok) { + recordGoalActivity(g, t('autoApprovedWrite'), false, 'agent'); + log(`[${g.goalId}] auto-approved write-access gate (${todo.todo_id})`); + } + } catch (err) { + log(`[${g.goalId}] auto-approve write gate failed: ${err.message || err}`, true); + } + } + if (writeGates.length) { + const reload = await app.call('loopx.listTodos', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + role: 'user', + status: 'open', + }); + g.userTodos = reload.ok ? reload.todos : g.userTodos; + // The gate may be cleared by the approvals: re-decide immediately so + // the goal resumes instead of waiting for the next heartbeat. + pollNow(g, { force: true }); + } + // Agent-lane todos carry the issue titles ("Fix GitHub issue #N: ") + // used as the gate items' background, and they persist across restarts. + try { + const agentRes = await app.call('loopx.listTodos', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + role: 'agent', + }); + g.agentTodos = agentRes.ok ? agentRes.todos : (g.agentTodos || []); + } catch (_) { + if (!g.agentTodos) g.agentTodos = []; + } + // Kick off the Chinese summaries for blocking items that lack one. + for (const todo of g.userTodos || []) { + if (gateTodoInfo(todo).isBlocking) ensureGateSummary(g, todo); + } + // Publish guidance needs the GitHub credential state: probe the local gh + // CLI once per session when it is still unknown. + if (S.ghAvailable === null) { + try { + const probe = await app.call('loopx.githubGhToken', {}); + S.ghAvailable = Boolean(probe && probe.ok); + } catch (_) { + S.ghAvailable = false; + } + } + } catch (err) { + log(`[${g.goalId}] listTodos error: ${err.message || err}`, true); + if (!g.userTodos) g.userTodos = []; + } finally { + g.userTodosLoading = false; + renderGoal(g); + } + return true; +} + +function notifyGate(g) { + const todos = g.userTodos || []; + const blocking = todos.filter((td) => gateTodoInfo(td).isBlocking).length; + const infoOnly = todos.length - blocking; + const body = t('notifGateBody', goalDisplayName(g), blocking, infoOnly); + try { + if (app.notifications?.system) { + app.notifications.system(t('notifGateTitle'), body); + } + } catch (_) {} + log(`[${g.goalId}] ${body}`, false); +} + +function syncGateState(g) { + const gated = isGated(g); + if (g.firstGateCheck) { + // First observation this session (boot / goal load): adopt the state + // silently. A gate that already existed must not fire a "historical" + // notification every time the console opens or a new task is created. + g.firstGateCheck = false; + if (gated) { + g.wasGated = true; + refreshUserTodos(g, true); + } else if (shouldTrackUserTodos(g)) { + // waiting_on may say codex while a publish approval is already open: + // discover it silently, then adopt the post-load state. + refreshUserTodos(g).then((ran) => { + if (ran && isLiveGoal(g)) { + g.wasGated = isGated(g); + requestRender(); + } + }); + } else { + g.wasGated = false; + } + return; + } + if (gated) { + if (!g.wasGated) { + // Load the concrete asks first so the notification names the first one + // instead of a generic "1 item". + refreshUserTodos(g, true).then(() => { if (isLiveGoal(g)) notifyGate(g); }); + } else { + refreshUserTodos(g); + } + } else if (shouldTrackUserTodos(g)) { + // Not gated by waiting_on: keep probing for open user todos so a publish + // approval surfaces even while loopx reports waiting_on=codex. Chain the + // re-evaluation only when a fetch actually ran (TTL skips stop the chain). + refreshUserTodos(g).then((ran) => { + if (ran && isLiveGoal(g)) syncGateState(g); + }); + } + g.wasGated = gated; +} + +// User todos only matter for goals this console owns and runs: archived goals +// are restore-only, other-host goals are not ours to approve. +function shouldTrackUserTodos(g) { + return !g.archived && isOwnedGoal(g.goalId); +} + +async function approveTodo(g, todo, note, button, opts = {}) { + if (button) button.disabled = true; + try { + // Publish gates publish by default: the console forks (when needed), + // pushes the branch and creates the PR first, then completes the todo so + // loopx reconciles the created PR instead of pushing on its own. The user + // can opt out of the PR submission (仅批准). + if (isPublishTodo(todo) && opts.publish !== false) { + let token = String(S.config.githubToken || '').trim(); + if (!token) { + // Reuse the machine's GitHub CLI credential when available — BitFun + // itself does not store GitHub tokens and delegates to gh auth. + try { + const probe = await app.call('loopx.githubGhToken', {}); + if (probe && probe.ok && probe.token) { + token = probe.token; + log(`[${g.goalId}] using local GitHub CLI credentials for publish`); + } + } catch (_) {} + if (!token) { + log(`[${g.goalId}] ${t('publishNeedToken')}`, true); + recordGoalActivity(g, t('publishNeedToken'), true); + if (button) button.disabled = false; + openTokenDialog(); + return false; + } + } + recordGoalActivity(g, t('publishWorking')); + try { + let analysis = null; + try { + recordGoalActivity(g, t('publishAnalyzing')); + analysis = await generateCauseAnalysis(g, todo); + } catch (_) { /* publish proceeds without the analysis */ } + const issueRefs = parseIssueUrls(String(todo.text || todo.title || '')); + const published = await app.call('loopx.publishPr', { + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + token, + title: prTitleFor(g), + body: prBodyFor(g), + branch: branchHintFromText(todo.text || todo.title || ''), + // The worker composes the real PR content from this: issue number + // in the title, "Fixes #N" binding, issue link + one-line + // description, the generated 原因/解决 analysis and the branch's + // commit subjects. + issueUrl: issueRefs.length ? issueRefs[0].url : null, + analysis, + }); + if (!published.ok) throw new Error(published.error || 'publish failed'); + recordGoalActivity(g, t('publishDone', published.prUrl), false, 'agent'); + log(`[${g.goalId}] PR published: ${published.prUrl} (fork=${published.forkUrl})`); + note = [note || '', `PR 已由控制台创建:${published.prUrl}`].filter(Boolean).join(' · '); + } catch (err) { + const message = String(err && err.message || err); + log(`[${g.goalId}] publish failed: ${message}`, true); + recordGoalActivity(g, `${t('publishFailed')}: ${message}`, true); + if (button) button.disabled = false; + return false; + } + } + if (isPublishTodo(todo) && opts.publish === false) { + note = [note || '', t('approveOnlyNote')].filter(Boolean).join(' · '); + } + const res = await app.call('loopx.completeTodo', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + todoId: todo.todo_id, + note: note || null, + // user_gate todos hard-require a decision outcome; other classes + // reject the flag, so send it only where the CLI demands it. + decisionOutcome: todo.task_class === 'user_gate' ? 'approve' : null, + }); + if (!res.ok) throw new Error(res.error || 'todo complete failed'); + const todoIsGate = todo.task_class === 'user_gate'; + log(`[${g.goalId}] ${todoIsGate ? t('approveDone') : t('todoDoneFeedback')} (${todo.todo_id})`); + // Approval is an explicit "go": clear any user stop and re-enable + // auto-run so the task proceeds to the next step without another click + // (the paused 继续/删除 buttons must not linger after a decision). + if (g.userStopped || !g.autoRun || !g.monitoring) { + g.userStopped = false; + delete S.config.stoppedByGoal[g.goalId]; + delete S.config.autoRunBeforeStop[g.goalId]; + g.monitoring = true; + S.config.monitorByGoal[g.goalId] = true; + g.autoRun = true; + S.config.autoRunByGoal[g.goalId] = true; + await saveConfig(); + log(`[${g.goalId}] ${t('approveResumed')}`); + } + // Reload the gate list fresh. A rapid second approval can land while the + // first reload is still in flight — wait it out so the just-completed + // todo is guaranteed to be reflected (otherwise the stale cached list + // keeps the second card visible for up to 60s). + while (g.userTodosLoading) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + await refreshUserTodos(g, true); + pollNow(g, { force: true }); // approval may clear the gate — re-decide immediately + return true; + } catch (err) { + log(`[${g.goalId}] ${t('approveFailed', err.message || err)}`, true); + if (button) button.disabled = false; + return false; + } +} + +// ── auto-run ────────────────────────────────────────────── +// Composer-created tasks run continuously: whenever loopx says should_run and +// no gate is open, fire the next turn without asking. Three consecutive +// failures trip the breaker so a broken setup can't loop forever. +const AUTO_RUN_FAIL_LIMIT = 3; + +// A goal object becomes an orphan when refreshGoals/project-switch replaces +// or drops it; async callbacks holding the old reference must go quiet. +function isLiveGoal(g) { + return S.goals.get(g.goalId) === g; +} + +function canAutoRun(g) { + return g.autoRun && !g.running && isLiveGoal(g) && !isGated(g) + // Only a FRESH successful decision may launch a turn — a failed poll + // leaves g.last stale (or ok:false) and must never fire on it. + && g.errorCount === 0 && g.last?.ok !== false + && g.last?.shouldRun === true && g.autoFailCount < AUTO_RUN_FAIL_LIMIT + && !!goalProjectDir(g.goalId) && !!g.agentId; +} + +function maybeAutoRun(g) { + if (!canAutoRun(g)) return; + log(`[${g.goalId}] ${t('autoRunNext')}`); + executeRunOnce(g).catch((err) => { + log(`[${g.goalId}] auto-run error: ${err.message || err}`, true); + g.running = false; + renderGoal(g); + }); +} + +function setAutoRun(g, enabled) { + g.autoRun = enabled; + g.autoFailCount = 0; + S.config.autoRunByGoal[g.goalId] = enabled; + saveConfig(); + if (enabled) maybeAutoRun(g); +} + +// ── stop / resume task ──────────────────────────────────── +// "停止任务" is a FULL stop, not a single-turn cancel: the in-flight run is +// cancelled, the loopx heartbeat (auto-poll) for this goal is switched off, +// auto-run is switched off, and the parked state persists across restarts. +// "取消运行" (cancelGoalRun) remains turn-scoped: it kills only the current +// run and prevents an immediate relaunch; polling continues. +async function stopGoalTask(g) { + if (g.running) cancelGoalRun(g, null); + S.config.autoRunBeforeStop[g.goalId] = g.autoRun === true; + if (g.autoRun) setAutoRun(g, false); + g.monitoring = false; + S.config.monitorByGoal[g.goalId] = false; + g.userStopped = true; + S.config.stoppedByGoal[g.goalId] = true; + await saveConfig(); + rearmTimer(); + log(t('taskStopped', g.goalId)); + recordGoalActivity(g, t('taskStopped', g.goalId)); + renderGoalDetails(g); + renderAllGoals(true); +} + +async function resumeGoalTask(g) { + delete S.config.stoppedByGoal[g.goalId]; + g.userStopped = false; + g.monitoring = true; + S.config.monitorByGoal[g.goalId] = true; + if (S.config.autoRunBeforeStop[g.goalId] === true) { + g.autoRun = true; + S.config.autoRunByGoal[g.goalId] = true; + } else if (S.config.autoRunBeforeStop[g.goalId] === false) { + g.autoRun = false; + S.config.autoRunByGoal[g.goalId] = false; + } + delete S.config.autoRunBeforeStop[g.goalId]; + await saveConfig(); + log(t('taskResumed', g.goalId)); + recordGoalActivity(g, t('taskResumed', g.goalId)); + renderGoalDetails(g); + pollNow(g); // immediate fresh decision; auto-run may launch from it + renderAllGoals(true); +} + +// ── pause / resume (lifecycle + visibility) ─────────────── +function pauseHeartbeat() { + if (S.paused) return; + S.paused = true; + if (S.timer) { clearTimeout(S.timer); S.timer = null; } +} + +function resumeHeartbeat() { + if (!S.paused) return; + S.paused = false; + const now = Date.now(); + for (const g of S.goals.values()) { + if (g.monitoring && !g.stopped && g.nextDueAt <= now) pollGoal(g); + } + rearmTimer(); +} + +// ── rendering ───────────────────────────────────────────── +function isGated(g) { + // After a successful poll, its waiting_on is authoritative (may be null); + // before one, fall back to the listGoals snapshot. waiting_on=controller is + // NOT a user gate: this console runs as scheduler_owner=outer_controller, + // i.e. it IS the controller — auto-run should take that turn, not park the + // goal in "needs you" with nothing approvable. + const w = g.last && g.last.ok !== false ? g.last.waitingOn : g.waitingOn; + if (w === 'user') return true; + const s = String(g.last?.state || g.state || '').toLowerCase(); + if (/gate|user_action|operator/.test(s)) return true; + // loopx may keep waiting_on=codex while an open user-lane todo (publish + // approval etc.) sits pending — the todo itself is the authoritative gate. + // Multi-issue goals run other issues in parallel, so waiting_on alone is + // NOT enough to surface a publish approval. Informational user todos + // (guidance, not decisions) must NOT gate: they would park the goal in the + // review column with nothing actionable to show. + if (Array.isArray(g.userTodos) && g.userTodos.some((td) => gateTodoInfo(td).isBlocking)) { + return true; + } + return false; +} + +// The board mirrors an issue tracker, but attention comes first: ONLY the two +// things that deserve a column exist — work that needs the human (blocking) +// and work that is running. Queued auto-run goals between turns are +// intentionally invisible (they surface the moment they run or need +// approval); paused/stopped/error goals stay visible as dimmed rail cards +// (so a restart can never make a task disappear), and terminal/other-host +// goals collapse into the quiet "more" chips footer. +const PRIMARY_GROUPS = ['review', 'active']; +const ARCHIVE_GROUPS = ['done', 'archived']; +const GROUP_I18N_KEY = { + backlog: 'groupBacklog', active: 'groupActive', review: 'groupReview', + done: 'groupDone', paused: 'groupPaused', error: 'groupError', + archived: 'groupArchived', +}; +const GROUP_SUB_KEY = { + review: 'colSubReview', active: 'colSubActive', +}; + +function isTerminal(g) { + const state = String(g.last?.state || g.state || '').toLowerCase(); + return /(^|_)(terminal|completed|complete|done|cancelled|canceled|duplicate|merged|closed)(_|$)/.test(state) + || state.includes('no_followup'); +} + +function goalGroup(g) { + // An archived task sits in the quiet 已归档 group with a 恢复 button — + // never in a hidden bucket, never mixed into running/paused states. + if (g.archived) return 'archived'; + if (isTerminal(g)) return 'done'; + if (g.userStopped) return 'paused'; + if (g.errorCount > 0) return 'error'; + if (g.stopped) return 'paused'; + // A human gate outranks a running turn: an approval request must surface in + // "needs you" the moment it opens, not hide behind "in progress". + if (isGated(g)) return 'review'; + if (g.running) return 'active'; + // Auto-run off without a running turn is a VISIBLE paused state — the boot + // sequence pauses every previous task (自动已关) so nothing auto-runs after + // a restart, and an auto-run disabled by repeated failures parks here too. + // These must never fall into 'backlog', which has no visible slot: a task + // that was running before a restart would silently vanish from the board + // instead of showing its card with 继续. + if (!g.autoRun) return 'paused'; + return 'backlog'; +} + +function fmtCountdown(ms) { + if (ms <= 0) return '0:00'; + const sec = Math.round(ms / 1000); + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +// One-glance answer to "is this task running normally?": a colored status +// chip per card replaces the global header heartbeat readout. +function goalStatus(g) { + if (g.archived) return { cls: 'goal-status--muted', text: t('statusArchived') }; + if (g.running) return { cls: 'goal-status--live', text: t('statusRunning') }; + if (g.userStopped || g.stopped) return { cls: 'goal-status--muted', text: t('statusPaused') }; + if (g.errorCount > 0) return { cls: 'goal-status--err', text: t('statusErroring', g.errorCount) }; + if (!g.monitoring) return { cls: 'goal-status--muted', text: t('statusUnmonitored') }; + if (!g.autoRun) return { cls: 'goal-status--muted', text: t('statusManual') }; + return { cls: 'goal-status--ok', text: t('statusAuto') }; +} + +function goalStatusChip(g) { + const s = goalStatus(g); + const chip = document.createElement('span'); + chip.className = `goal-status ${s.cls}`; + chip.textContent = s.text; + return chip; +} + +// Progress stage: which issue the state machine is on — makes "how far from +// the PR confirmation" visible at a glance on the card and in the panel. +function goalStageText(g) { + if (isTerminal(g)) return null; // the conclusion line covers finished tasks + if (isGated(g)) return t('stageGated'); + if (g.running) return t('stageRunning'); + const action = String(g.last?.recommendedAction || g.last?.recommended_action || ''); + const m = action.match(/issue #(\d+)/i); + if (m) return t('stageFixing', m[1]); + return t('stagePlanning'); +} + +function showRawJson(g) { + document.getElementById('raw-title').textContent = `${g.goalId} · ${t('raw')}`; + document.getElementById('raw-body').textContent = g.last?.raw + ? JSON.stringify(g.last.raw, null, 2) + : JSON.stringify(g.last ?? {}, null, 2); + document.getElementById('dlg-raw').showModal(); +} + +function renderGoal(_g) { + renderAllGoals(); +} + +function goalNarration(g) { + // Issue goals: the issue chips row IS the narration (count + per-issue + // status chips with titles on hover). Writing the title/progress again in + // prose would duplicate both the strip and the gate card's 背景 — human + // views stay de-duplicated. + if (objectiveHasIssueSignal(g.objective || '')) return ''; + return g.objective || g.lastError + || (g.archived ? t('statusArchived') : g.last?.state || g.state || g.goalId || ''); +} + +// The one-line answer to "was it fixed, and why not?" for finished tasks — +// loopx terminal states mapped to plain Chinese. +function goalConclusion(g) { + const state = String(g.last?.state || g.state || '').toLowerCase(); + if (/(^|_)merged(_|$)/.test(state)) return t('conclusionMerged'); + if (/(^|_)(cancelled|canceled)(_|$)/.test(state)) return t('conclusionCancelled'); + if (/(^|_)(closed|duplicate)(_|$)/.test(state)) return t('conclusionClosed'); + if (state.includes('no_followup')) return t('conclusionNoFollowup'); + if (/(^|_)(terminal|completed|complete|done)(_|$)/.test(state)) return t('conclusionCompleted'); + return t('conclusionFinished'); +} + +// Friendly display name: "<repo>#<n>" from the intake link, the bare repo +// slug, or the clone-cache folder name. The raw goalId (loopx's identity) +// always stays reachable as a tooltip, so nothing becomes unfindable. +function goalDisplayName(g) { + const text = String(g.objective || ''); + const issue = text.match(/github\.com\/[^/\s]+\/([^/\s]+)\/(?:issues|pull)\/(\d+)/i); + if (issue) return `${issue[1].replace(/\.git$/i, '')}#${issue[2]}`; + const repo = text.match(/github\.com\/[^/\s]+\/([^/\s?#]+)/i); + if (repo) return repo[1].replace(/\.git$/i, ''); + const dir = String(goalProjectDir(g.goalId) || ''); + const base = dir.split(/[\\/]/).filter(Boolean).pop() || ''; + if (base) return base; + return String(g.goalId || '').replace(/^bfx-/, ''); +} + +// waiting_on values are loopx identifiers ('user', 'controller', …); translate +// the one that means the user instead of leaking raw ids into the UI. 'codex' +// is loopx's legacy label for the agent execution lane — in outer_controller +// mode that lane is BitFun's own agent, so surface it as friendly text too. +function waitingLabel(w) { + if (!w) return null; + const v = String(w).toLowerCase(); + if (v === 'user') return t('groupReview'); + if (v === 'codex' || v === 'agent') return t('waitingAgent'); + return String(w); +} + +// loopx authors todo texts in its own words; the console frames them with a +// type label in the UI language so the "needs you" list reads clearly without +// needing an LLM to translate arbitrary gate content. +const GATE_ACTION_LABELS = [ + [/publish|external_review|reviewer|pr_|pull_request/i, '发布 / 提 PR / 外部评审'], + [/approval|approve/i, '审批'], + [/credential|secret|private/i, '凭据 / 私密材料'], + [/production|deploy|release/i, '生产 / 发布操作'], + [/submission|leaderboard|public_claim/i, '提交 / 公开宣称'], + [/boundary/i, '边界授权'], +]; +function gateActionLabel(todo) { + const kind = String(todo?.action_kind || todo?.actionKind || todo?.task_class || todo?.taskClass || ''); + if (!kind) return null; + for (const [re, label] of GATE_ACTION_LABELS) if (re.test(kind)) return label; + return null; +} + +// Chinese one-line explanations for the frequent loopx gate wordings; the +// exact original stays available behind 查看原文. +const GATE_TEXT_HINTS = [ + [/write access|read-?only|connected-read-only/i, 'gateExplainWrite'], + [/approve or reject|approve|reject/i, 'gateExplainDecide'], + [/publish|pull ?request/i, 'gateExplainPublish'], + [/merge/i, 'gateExplainMerge'], + [/review/i, 'gateExplainReview'], + [/preload|electron/i, 'gateExplainPreload'], +]; +function gateExplainHints(raw) { + const text = String(raw || ''); + const out = []; + for (const [re, key] of GATE_TEXT_HINTS) if (re.test(text) && !out.includes(key)) out.push(key); + return out; +} +function gateExplain(raw) { + const hints = gateExplainHints(raw); + return hints.length ? hints[0] : null; +} + +// Issue titles from the goal's agent todos: the gate text usually only names +// "#164" while the fix todo carries the full issue title (persisted). +function issueTitlesFor(g, raw) { + const todos = Array.isArray(g.agentTodos) ? g.agentTodos : []; + const out = []; + for (const m of String(raw || '').matchAll(/#(\d+)\b/g)) { + const n = m[1]; + if (out.some((t) => t.startsWith(`#${n} `))) continue; + const hit = todos.find((td) => String(td.text || td.title || '').includes(`issue #${n}:`)); + const tm = hit && String(hit.text || hit.title || '').match(/issue #\d+:\s*(.+?)\s*\(https?:/i); + out.push(tm ? `#${n} ${tm[1]}` : `#${n}`); + } + return out; +} + +// ── Chinese gate summaries (host agent) ───────────────────── +// Each blocking gate item gets a 3-line Chinese summary (背景 / 已完成 / +// 需要你) generated by the host agent itself — the only reliable way to turn +// loopx's English wording into the context the user actually needs. Runs are +// hidden, tool-less, cached per goal, and never touch the goal's own session. +const summaryRuns = new Map(); // sessionId -> { goalId, todoId, buffer } + +// The summary model may reason aloud before answering ("The user wants a +// concise 3-line summary… Let me draft.") — the wall of reasoning must never +// reach the human-facing card. Parse the REAL answer instead: the last +// occurrence of each labeled line (背景/已完成/需要你) in order. Extraction +// beats filtering because reasoning text is arbitrary and unfilterable. +const GATE_SUMMARY_LABELS = [/^背景[::]/, /^已完成[::]/, /^需要你[::]/]; +function extractGateSummary(text) { + const lines = String(text || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const lastIndexOf = (re) => { + for (let i = lines.length - 1; i >= 0; i -= 1) if (re.test(lines[i])) return i; + return -1; + }; + const idx = GATE_SUMMARY_LABELS.map(lastIndexOf); + if (idx.every((i) => i >= 0) && idx[0] < idx[1] && idx[1] < idx[2]) { + return [lines[idx[0]], lines[idx[1]], lines[idx[2]]].join('\n'); + } + return null; +} + +// Fallback: drop obvious self-talk lines; if that empties the text, keep the +// raw tail (last few lines) rather than nothing. +const SUMMARY_SELFTALK_RE = /^(我需要|我要|我会|我将|让我|首先|接下来|好的|那么|I need to|I will|I'm going to|Let me|First|Next|Okay|The user wants|Should I|But wait|Need to|Format|Original|Check|Each|Let's|Let me check|Could|Final)[,,.::\s]/i; +function stripSummarySelfTalk(text) { + const lines = String(text || '').split(/\r?\n/); + const kept = lines.filter((line) => { + const t = line.trim(); + if (!t) return false; + if (SUMMARY_SELFTALK_RE.test(t)) return false; + return true; + }); + if (kept.length) return kept.join('\n').trim(); + const tail = lines.slice(-5).filter((line) => line.trim()); + return tail.length ? tail.join('\n').trim() : String(text || '').trim(); +} + +// The one gate-summary entry point: structured extraction first, filtering +// as the safety net. Never raw model output. +function cleanGateSummary(text) { + return extractGateSummary(text) || stripSummarySelfTalk(text); +} + +// ── publish-time cause/solution analysis ─────────────────── +// PR bodies need "why it broke + how it was fixed". A one-shot agent run +// (no tools) reads the issue title, branch and commit subjects and answers +// with exactly two labeled lines (原因:/解决:). Runs are tracked like the +// gate summaries; a 60s cap resolves null so publish never blocks on it. +const analysisRuns = new Map(); // sessionId -> { goalId, resolve, buffer, timer } +function extractLabeledLines(text, labels) { + const lines = String(text || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const out = {}; + for (const key of labels) { + const re = new RegExp(`^${key}[::]\\s*(.*)$`); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const m = lines[i].match(re); + if (m) { out[key] = m[1].trim() || null; break; } + } + } + return labels.some((key) => out[key]) + ? { cause: out['原因'] || null, solution: out['解决'] || null } + : null; +} + +function generateCauseAnalysis(g, todo) { + return new Promise((resolve) => { + const finish = (value) => resolve(value); + let timer = null; + (async () => { + const raw = String(todo.text || todo.title || ''); + const titles = issueTitlesFor(g, raw); + const branch = branchHintFromText(raw); + let subjects = []; + let files = []; + let stat = null; + try { + const gl = await app.call('loopx.gitLog', { projectDir: goalProjectDir(g.goalId), branch: branch || null }); + if (gl && gl.ok && Array.isArray(gl.subjects)) subjects = gl.subjects.slice(0, 10); + } catch (_) {} + try { + const gd = await app.call('loopx.gitDiff', { projectDir: goalProjectDir(g.goalId), branch: branch || null }); + if (gd && gd.ok) { files = (gd.files || []).slice(0, 30); stat = gd.stat || null; } + } catch (_) {} + const prompt = [ + '根据下面的修复任务信息,用中文输出恰好两行:第一行以「原因:」开头(问题出现的根因),第二行以「解决:」开头(如何解决的——具体到改了哪些文件、每处改动解决了什么问题)。不要输出任何其他内容(不要思考过程、开场白或解释)。', + `Issue:${titles.join(';') || raw.slice(0, 200)}`, + `分支:${branch || '?'}`, + files.length ? `涉及文件${stat ? `(${stat})` : ''}:\n${files.map((f) => `- ${f}`).join('\n')}` : '', + subjects.length ? `提交记录:\n${subjects.map((s) => `- ${s}`).join('\n')}` : '', + ].filter(Boolean).join('\n'); + try { + const run = await app.agent.run(prompt, { + sessionName: `bitfun-loopx PR 分析 · ${goalDisplayName(g)}`, + enableTools: false, + model: S.config.defaultModel || 'auto', + }); + timer = setTimeout(() => { + if (analysisRuns.has(run.sessionId)) analysisRuns.delete(run.sessionId); + finish(null); + }, 60000); + analysisRuns.set(run.sessionId, { + goalId: g.goalId, resolve: finish, buffer: '', timer, + }); + } catch (_) { + if (timer) clearTimeout(timer); + finish(null); + } + })(); + }); +} + +// Renders the three labeled summary lines directly on the card (no gray box): +// each line becomes its own row with the 背景/已完成/需要你 label bolded. +function appendLabeledSummary(container, text) { + for (const rawLine of String(text || '').split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const row = document.createElement('div'); + const m = line.match(/^(背景|已完成|需要你)[::]\s*(.*)$/); + if (m) { + const label = document.createElement('strong'); + label.textContent = `${m[1]}:`; + row.append(label, m[2] || ''); + } else { + row.textContent = line; + } + container.appendChild(row); + } +} + +async function ensureGateSummary(g, todo) { + if (!isLiveGoal(g) || !todo || !todo.todo_id) return; + if (!g.gateSummaries) g.gateSummaries = new Map(); + // Done summaries persist across sessions (loaded at boot); loading ones are + // in flight. Anything else (missing / failed / empty) retries so the card + // always converges on a ready summary without user interaction. + const existing = g.gateSummaries.get(todo.todo_id); + if (existing && (existing.status === 'done' || existing.status === 'loading')) return; + g.gateSummaries.set(todo.todo_id, { status: 'loading' }); + const titles = issueTitlesFor(g, todo.text || todo.title || ''); + const prompt = [ + '用中文输出恰好三行,每行不超过 60 字;第一行必须以「背景:」开头,第二行必须以「已完成:」开头,第三行必须以「需要你:」开头。不要输出任何其他内容(不要思考过程、开场白、解释或多余换行):', + `背景:${titles.length ? titles.join(';') : '(见原文)'}`, + '已完成:该事项涉及的工作或改动', + '需要你:用户现在需要做的决定或操作', + '', + `原文:${todo.text || todo.title || todo.todo_id}`, + ].join('\n'); + try { + const run = await app.agent.run(prompt, { + sessionName: `bitfun-loopx 摘要 · ${goalDisplayName(g)}`, + enableTools: false, + model: S.config.defaultModel || 'auto', + }); + summaryRuns.set(run.sessionId, { + goalId: g.goalId, todoId: todo.todo_id, buffer: '', + sessionId: run.sessionId, turnId: run.turnId, + }); + } catch (err) { + dbgUi('gateSummary:runError', String(err && err.message || err)); + if (isLiveGoal(g)) g.gateSummaries.set(todo.todo_id, { status: 'failed' }); + } +} + +// Publish-scope gates (external PR creation / review request) trigger the +// console's own PR flow on approval — submitting the PR IS the default. +const PUBLISH_TODO_RE = /publish|external_review|reviewer|pr_|pull_request/i; +function isPublishTodo(todo) { + const meta = String(todo?.action_kind || todo?.actionKind || todo?.task_class || todo?.taskClass || ''); + const text = String(todo?.title || todo?.text || ''); + // loopx writes publish gates with varying metadata: some carry + // action_kind=external_pr_creation, others only a user_action todo whose + // TEXT names the publish/PR ask ("推送 fix/… 分支并为 issue #N 创建 PR + // (publish 需 owner 审批)"). Match both so the approval never surfaces + // as a mere informational item without the publish action. + return PUBLISH_TODO_RE.test(meta) + || /publish|external_review|pull request|创建\s*(PR|pull)|提交\s*(PR|pull)/i.test(text); +} + +// GitHub credential state for the publish guidance: the host-level gh CLI +// credential, a configured PAT, or nothing yet — drives the step-by-step +// 等你处理 guidance. +function githubCredState() { + if (String(S.config.githubToken || '').trim()) { + return { mode: 'token', label: t('gateCredToken', S.config.githubLogin || '?') }; + } + if (S.ghAvailable === true) return { mode: 'gh', label: t('gateCredGh') }; + return { mode: 'none', label: t('gateCredNone') }; +} + +// PR identity markers — the countability contract: every PR created by this +// tool carries both keywords in its title, searchable on GitHub with +// `"bitfun-loopx" in:title`. +const PR_TITLE_PREFIX = '[bitfun-loopx] '; +const PR_BODY_MARKER = 'Created by bitfun-loopx (BitFun built-in MiniApp).'; + + +function prTitleFor(g) { + return `${PR_TITLE_PREFIX}${goalDisplayName(g)}`; +} + +function prBodyFor(g) { + const issueMatch = String(g.objective || '').match(/github\.com\/[^/\s]+\/[^/\s]+\/(?:issues|pull)\/(\d+)/i); + const lines = []; + if (issueMatch) lines.push(`Fixes #${issueMatch[1]}`); + lines.push(PR_BODY_MARKER); + return lines.join('\n\n'); +} + +// The publish gate's wording usually names the fix branch ("Push +// fix/issue-216-… to a fork…"). Pushing the named branch instead of HEAD +// keeps per-issue branches separate inside the one-repo goal. +function branchHintFromText(text) { + const s = String(text || ''); + const pushMatch = s.match(/(?:push|branch)\s+([A-Za-z0-9][A-Za-z0-9._/-]{0,120})/i); + if (pushMatch) { + const token = pushMatch[1].replace(/[),.;:]+$/, ''); + if (/^(fix|feature|codex|issue|patch)[\/-]/i.test(token)) return token; + } + const m = s.match(/(?:fix|feature|codex|issue|patch)[\/-][A-Za-z0-9._-]{2,}/i); + return m ? m[0].replace(/[),.;:]+$/, '') : null; +} + +// Classify one user todo: BLOCKING gates (user_gate / publish scope) need a +// real decision before the task continues; everything else is informational +// (guidance instructions etc.) and only needs to be acknowledged. +function gateTodoInfo(todo) { + const isPublish = isPublishTodo(todo); + const isBlocking = todo.task_class === 'user_gate' || isPublish; + const raw = todo.title || todo.text || todo.todo_id || ''; + const mapped = gateActionLabel(todo); + const typeLabel = mapped || (isPublish ? t('gateTypePublish') : (isBlocking ? t('gateTypeApprove') : t('gateTypeInfo'))); + // Chinese-first everywhere: the card title is always a Chinese label; + // loopx's raw wording (or the user's own instruction text) stays as a dim + // secondary line, so no todo ever surfaces as bare English. + const title = isBlocking + ? t('gateItemWithType', typeLabel) + : (mapped ? t('gateItemInfoLabel', typeLabel) : t('gateTypeInfo')); + return { isBlocking, isPublish, typeLabel, title, raw }; +} + +// One gate item as a card: Chinese label + the three-line summary + credential +// state + after-approval note + the action button. No task kicker and no +// separate 背景 line — the goal card's head already names the task, and the +// three-line summary's 背景 covers the issue context (no repeated layers). +function buildGateItemCard(g, todo) { + const info = gateTodoInfo(todo); + const card = document.createElement('div'); + card.className = `gate-card ${info.isBlocking ? 'gate-card--block' : 'gate-card--info'}`; + const title = document.createElement('div'); + title.className = 'gate-card__title'; + const label = document.createElement('span'); + label.className = 'gate-card__label'; + label.textContent = info.title; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `btn btn--tiny ${info.isBlocking ? 'btn--approve' : ''}`; + btn.textContent = info.isPublish + ? t('approveAndPr') + : (info.isBlocking ? t('approveGate') : t('completeTodoBtn')); + btn.onclick = (ev) => { ev.stopPropagation(); openApproveDialog(g, todo); }; + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'btn btn--tiny'; + copyBtn.textContent = t('copy'); + copyBtn.title = t('copyCardHint'); + copyBtn.onclick = async (ev) => { + ev.stopPropagation(); + const summary = g.gateSummaries && g.gateSummaries.get(todo.todo_id); + const titles = issueTitlesFor(g, info.raw); + const text = [ + t('gateCardTask', goalDisplayName(g)), + info.title, + titles.length ? `${t('gateBackground')}${titles.join(';')}` : '', + summary && summary.status === 'done' && summary.text + ? summary.text + : gateExplainHints(info.raw).map((key) => t(key)).join('\n'), + info.raw, + ].filter(Boolean).join('\n'); + try { + if (app.clipboard && app.clipboard.writeText) await app.clipboard.writeText(text); + else await navigator.clipboard.writeText(text); + copyBtn.textContent = '✓'; + setTimeout(() => { copyBtn.textContent = t('copy'); }, 1200); + } catch (_) { + // Clipboard unavailable: leave the button as-is. + } + }; + title.append(label, copyBtn, btn); + card.appendChild(title); + if (info.isBlocking) { + // The three-line summary's 背景 line carries the issue titles — no + // separate background block here (de-duplicated human view). + const summary = g.gateSummaries && g.gateSummaries.get(todo.todo_id); + if (summary && summary.status === 'done' && summary.text) { + const sum = document.createElement('div'); + sum.className = 'gate-card__summary'; + // Plain lines on the card (no gray box); labels bolded for scannability. + appendLabeledSummary(sum, summary.text); + card.appendChild(sum); + } else if (summary && summary.status === 'loading') { + const loading = document.createElement('div'); + loading.className = 'gate-card__summary gate-card__summary--loading'; + loading.textContent = t('gateSummaryLoading'); + card.appendChild(loading); + } else { + // No summary yet: show the pattern-based Chinese hints immediately and + // generate the full 背景/已完成/需要你 summary in the background. + for (const key of gateExplainHints(info.raw)) { + const hintEl = document.createElement('div'); + hintEl.className = 'gate-card__explain'; + hintEl.textContent = t(key); + card.appendChild(hintEl); + } + ensureGateSummary(g, todo); + } + // Publish guidance, step by step: show the GitHub credential state and, + // when nothing is logged in yet, a setup action right on the card. + if (info.isPublish) { + const cred = githubCredState(); + const credLine = document.createElement('div'); + credLine.className = `gate-card__cred gate-card__cred--${cred.mode}`; + credLine.textContent = cred.label; + card.appendChild(credLine); + if (cred.mode === 'none') { + const setup = document.createElement('button'); + setup.type = 'button'; + setup.className = 'btn btn--tiny'; + setup.textContent = t('gateCredSetup'); + setup.onclick = (ev) => { ev.stopPropagation(); openTokenDialog(); }; + card.appendChild(setup); + } + } + // "用户做了什么之后会发生什么": every decision card answers what + // happens next, so the human never approves blind. + const after = document.createElement('div'); + after.className = 'gate-card__after'; + after.textContent = info.isPublish ? t('gateAfterPublish') : t('gateAfterApprove'); + card.appendChild(after); + } + // The loopx original wording is internal noise for the human template + // (label + background + three-line summary + after-approval already cover + // it) — no 查看原文 collapsible. Anyone who needs the exact text can copy + // it via the 复制 button, which embeds info.raw. + return card; +} + +// Blocking first, informational second — two clearly labelled groups. +function buildGateItemsList(g) { + const list = document.createElement('div'); + list.className = 'gate-items'; + const todos = g.userTodos || []; + // The review column is a DECISION queue, not an inbox: only blocking items + // (user_gate / publish scope) need a human decision. Informational user + // todos (guidance loopx wrote for the agent/user, "用户需要 xxx" style) + // stay internal — loopx reconciles them without the console echoing them. + const blocking = todos.filter((td) => gateTodoInfo(td).isBlocking); + for (const td of blocking) list.appendChild(buildGateItemCard(g, td)); + return list; +} + +function activityText(line) { + const text = String(line || '') + .replace(/^\s*(?:\[[^\]]+\]\s*)+/, '') + .replace(/\s+/g, ' ') + .trim(); + return text.length > 150 ? `${text.slice(0, 147)}...` : text; +} + +// Keep the stream DOM light: multi-KB reasoning blocks are the single biggest +// layout cost in the log panel. Only the newest tail renders; the full text +// stays in the persisted log (and the raw dialog), never in the DOM. +// Per-kind DOM tail windows: reasoning is auxiliary (small window), the +// model's visible output gets a more generous one. +const STREAM_DOM_CAPS = { think: 2000, agent: 6000, prompt: 4000 }; +function cappedStreamText(text, cap) { + const s = String(text || ''); + if (s.length <= cap) return s; + return `…${t('streamTrimmed', s.length - cap)}…\n${s.slice(-cap)}`; +} + +function activityDisplayText(entry) { + return entry.count > 1 ? `${entry.line} ×${entry.count}` : entry.line; +} + +function activityLineElement(entry) { + const row = document.createElement('div'); + row.className = 'activity-stream__line' + + (entry.isErr ? ' activity-stream__line--err' : '') + + (entry.kind ? ` activity-stream__line--${entry.kind}` : ''); + const time = document.createElement('span'); + time.className = 'activity-stream__time'; + time.textContent = entry.time; + row.appendChild(time); + if (entry.kind === 'think' && entry.stream) { + // Reasoning renders as ONE block, streamed in place and expanded by + // default — never a wall of per-paragraph "thinking" rows. Only the + // newest tail goes into the DOM (full text stays persisted). + const details = document.createElement('details'); + details.className = 'activity-prompt activity-prompt--think'; + // Collapsed by default: reasoning is the agent's internal thinking — the + // human-facing log leads with actions and output; thinking stays one + // click away. + details.open = false; + const summary = document.createElement('summary'); + summary.textContent = t('thinkBlockTitle'); + const pre = document.createElement('pre'); + pre.textContent = cappedStreamText(entry.line, STREAM_DOM_CAPS.think); + details.append(summary, pre); + row.appendChild(details); + } else if (entry.kind === 'prompt' && entry.raw) { + // The instructions sent to the agent: collapsed by default, expandable. + // Capped in the DOM so one 6KB+ prompt cannot bloat the stream. + const details = document.createElement('details'); + details.className = 'activity-prompt'; + const summary = document.createElement('summary'); + summary.textContent = activityDisplayText(entry); + const pre = document.createElement('pre'); + pre.textContent = cappedStreamText(entry.raw, STREAM_DOM_CAPS.prompt); + details.append(summary, pre); + row.appendChild(details); + } else { + const text = document.createElement('span'); + text.className = 'activity-stream__text'; + // Streamed model output stays capped too — the raw view keeps the whole + // text, the DOM only needs a readable tail window. + text.textContent = entry.stream + ? cappedStreamText(entry.line, STREAM_DOM_CAPS.agent) + : activityDisplayText(entry); + row.appendChild(text); + } + return row; +} + +function recordGoalActivity(g, line, isErr = false, kind = null, raw = null) { + const summary = kind === 'agent' ? String(line).trim() : activityText(line); + if (!summary) return; + if (!Array.isArray(g.activityLines)) g.activityLines = []; + if (typeof g.currentActivity !== 'string') g.currentActivity = ''; + const now = new Date().toTimeString().slice(0, 8); + // Collapse back-to-back repeats (tool streams spam the same verb) into one + // line with a multiplier instead of a wall of identical rows. + const last = g.activityLines[g.activityLines.length - 1]; + if (last && !last.isErr && !isErr && !last.isTick && !last.kind && !kind + && last.line === summary) { + last.count = (last.count || 1) + 1; + last.time = now; + const stream = document.querySelector(`.activity-stream[data-goal="${CSS.escape(g.goalId)}"]`); + if (stream && stream.lastElementChild) { + const textEl = stream.lastElementChild.querySelector('.activity-stream__text'); + if (textEl) textEl.textContent = activityDisplayText(last); + const timeEl = stream.lastElementChild.querySelector('.activity-stream__time'); + if (timeEl) timeEl.textContent = now; + } + g.currentActivity = summary; + scheduleLogSave(); + return; + } + const entry = { time: now, line: summary, isErr, count: 1, kind, raw }; + g.activityLines.push(entry); + if (g.activityLines.length > 240) g.activityLines.splice(0, g.activityLines.length - 240); + g.currentActivity = summary; + + const cardText = document.querySelector(`.goal__activity-text[data-goal="${CSS.escape(g.goalId)}"]`); + if (cardText) cardText.textContent = summary; + + const stream = document.querySelector(`.activity-stream[data-goal="${CSS.escape(g.goalId)}"]`); + if (stream) { + const follow = streamAtTail(stream); + const emptyEl = stream.querySelector('.activity-empty'); + if (emptyEl) emptyEl.remove(); + stream.appendChild(activityLineElement(entry)); + while (stream.children.length > 240) stream.removeChild(stream.firstChild); + if (follow) streamFollowTail(stream); + } else { + const panel = document.getElementById('goal-detail-panel'); + if (!panel.hidden && S.activeGoalId === g.goalId) renderGoalDetails(g); + } + scheduleLogSave(); +} + +// The once-per-10s running clock is one LINE that updates in place, not a new +// row every tick — the stream stays readable while the card text stays live. +function setGoalActivityTick(g, text) { + const summary = activityText(text); + if (!summary) return; + if (!Array.isArray(g.activityLines)) g.activityLines = []; + if (typeof g.currentActivity !== 'string') g.currentActivity = ''; + const now = new Date().toTimeString().slice(0, 8); + g.currentActivity = summary; + const cardText = document.querySelector(`.goal__activity-text[data-goal="${CSS.escape(g.goalId)}"]`); + if (cardText) cardText.textContent = summary; + const last = g.activityLines[g.activityLines.length - 1]; + const stream = document.querySelector(`.activity-stream[data-goal="${CSS.escape(g.goalId)}"]`); + if (last && last.isTick) { + last.line = summary; + last.time = now; + if (stream && stream.lastElementChild) { + const follow = streamAtTail(stream); + const textEl = stream.lastElementChild.querySelector('.activity-stream__text'); + if (textEl) textEl.textContent = summary; + const timeEl = stream.lastElementChild.querySelector('.activity-stream__time'); + if (timeEl) timeEl.textContent = now; + // A tick is an in-place time refresh, not new content: follow only when + // the user was already at the bottom. + if (follow) streamFollowTail(stream); + } + return; + } + const entry = { time: now, line: summary, isErr: false, count: 1, isTick: true }; + g.activityLines.push(entry); + if (g.activityLines.length > 240) g.activityLines.splice(0, g.activityLines.length - 240); + if (stream) { + const follow = streamAtTail(stream); + const emptyEl = stream.querySelector('.activity-empty'); + if (emptyEl) emptyEl.remove(); + stream.appendChild(activityLineElement(entry)); + // Keep the DOM in lockstep with the array: without this cap the row + // indices desync after 240 lines and the in-place stream patches would + // hit older rows (the log visibly "jumping back" to old content). + while (stream.children.length > 240) stream.removeChild(stream.firstChild); + if (follow) streamFollowTail(stream); + } else { + const panel = document.getElementById('goal-detail-panel'); + if (!panel.hidden && S.activeGoalId === g.goalId) renderGoalDetails(g); + } + scheduleLogSave(); +} + +// Intake draft as a pending directory row in the 进行中 rail; its stage line +// is patched in place by the taskIntake progress events. +function buildIntakeRow(draft) { + const el = document.createElement('div'); + el.className = 'run-item run-item--pending'; + const dot = document.createElement('span'); + dot.className = 'dot dot--active'; + const meta = document.createElement('span'); + meta.className = 'run-item__meta'; + const id = document.createElement('span'); + id.className = 'run-item__id'; + id.textContent = t('taskPendingLabel'); + const text = document.createElement('span'); + text.className = 'run-item__text'; + text.textContent = draft.objective; + meta.append(id, text); + const stage = document.createElement('span'); + stage.className = 'goal__activity-text'; + stage.textContent = draft.stage; + el.append(dot, meta, stage); + return el; +} + +// The 进行中 rail is a directory: one compact row per running goal. Clicking +// a row selects it and streams its log into the panel beside the rail. +// Lifecycle buttons live on the goal cards/rows themselves: 中止/继续 + +// 删除, with stopPropagation so they never toggle the selection underneath. +// The card's 继续 covers three paused flavours: an explicit stop (restore +// heartbeat + auto-run), a stopped tick loop (fresh poll), and the +// boot-paused state (auto-run off) — re-arm and re-decide immediately. +function resumeCardTask(g) { + if (g.userStopped) { resumeGoalTask(g); return; } + if (g.stopped) { pollNow(g); return; } + setAutoRun(g, true); + pollNow(g, { force: true }); +} + +function buildGoalActions(g, column) { + const box = document.createElement('span'); + box.className = 'goal-actions' + (column ? ' goal-actions--column' : ''); + const primary = (g.userStopped || g.stopped || !g.autoRun) + ? { label: t('resumeTask'), kind: 'primary', handler: () => resumeCardTask(g) } + : { label: t('stopTask'), kind: 'danger', handler: () => openStopConfirm(g) }; + const pb = document.createElement('button'); + pb.type = 'button'; + pb.className = `btn btn--tiny ${primary.kind === 'primary' ? 'btn--primary' : 'btn--danger'}`; + pb.textContent = primary.label; + pb.title = primary.kind === 'primary' ? t('resumeTaskHint') : t('stopTaskHint'); + pb.onclick = (ev) => { ev.stopPropagation(); primary.handler(); }; + box.appendChild(pb); + const db = document.createElement('button'); + db.type = 'button'; + db.className = 'btn btn--tiny btn--danger'; + db.textContent = t('deleteTask'); + db.title = t('deleteTaskHint'); + db.onclick = (ev) => { ev.stopPropagation(); openDeleteConfirm(g); }; + box.appendChild(db); + return box; +} + +function buildRunItem(g, parked = false) { + const el = document.createElement('div'); + el.className = 'run-item' + (parked ? ' run-item--parked' : '') + + (S.activeGoalId === g.goalId ? ' is-selected' : ''); + el.setAttribute('aria-label', g.goalId); + el.onclick = () => openGoalDetails(g); + const dot = document.createElement('span'); + dot.className = parked + ? `dot dot--${g.errorCount > 0 ? 'error' : 'paused'}` + : 'dot dot--active'; + const meta = document.createElement('span'); + meta.className = 'run-item__meta'; + const id = document.createElement('span'); + id.className = 'run-item__id'; + id.textContent = goalDisplayName(g); + id.title = g.goalId; + const text = document.createElement('span'); + text.className = 'run-item__text'; + text.textContent = goalNarration(g); + meta.append(id, text); + // Per-issue progress on the directory row itself: the 进行中 rail is where + // running goals live, so the issue board must be visible right here (the + // full card variant only appears in the review column). + const issueStrip = buildIssueStrip(g, { rail: true }); + if (issueStrip) meta.append(issueStrip); + el.append(dot, meta, goalStatusChip(g), buildGoalActions(g, true)); + return el; +} + +// v3.2: goals created by other loopx hosts on this machine. Listed in a +// collapsed section; each row offers one-click adoption (register agent + +// start heartbeat). Execution turns still require a known project directory. +async function adoptGoal(g, btn) { + if (btn) btn.disabled = true; + const agentId = g.agents[0] || resolveDefaultAgent(); + try { + const res = await app.call('loopx.adoptGoal', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId) || S.config.projectDir, + goalId: g.goalId, + agentId, + }); + if (!res.ok) throw new Error(res.error || 'adopt failed'); + S.config.ownedGoals[g.goalId] = true; + S.config.agentByGoal[g.goalId] = agentId; + S.config.monitorByGoal[g.goalId] = true; + await saveConfig(); + g.agentId = agentId; + g.monitoring = true; + log(`[${g.goalId}] ${t('adoptedLabel')}`); + renderAllGoals(true); + pollNow(g); + } catch (err) { + log(`[${g.goalId}] ${t('adoptFailed', err.message || err)}`, true); + if (btn) btn.disabled = false; + renderAllGoals(true); + } +} + +function buildOtherGoalsRows(goals) { + const body = document.createElement('div'); + body.className = 'board-more__rows'; + const hint = document.createElement('p'); + hint.className = 'board-more__hint'; + hint.textContent = t('otherTasksHint'); + body.appendChild(hint); + for (const g of goals) { + const row = document.createElement('div'); + row.className = 'other-tasks__row'; + const meta = document.createElement('div'); + meta.className = 'other-tasks__meta'; + const id = document.createElement('span'); + id.className = 'other-tasks__id'; + id.textContent = goalDisplayName(g); + id.title = g.goalId; + const narration = document.createElement('span'); + narration.className = 'other-tasks__text'; + narration.textContent = goalNarration(g); + narration.title = goalNarration(g); + meta.append(id, narration); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn--small'; + btn.textContent = t('adopt'); + btn.onclick = () => adoptGoal(g, btn); + row.append(meta, btn); + body.appendChild(row); + } + return body; +} + +// ── per-goal issue tracker (card strip) ──────────────────── +// Batch goals fix many issues: intake writes one agent todo per issue +// ("Fix GitHub issue #N: <title> (<url>)"), so the per-issue board is a +// projection over those todos (open / blocked / deferred / done). The strip +// lists the objective's issue URLs with status chips; single-issue goals +// derive their chip state from the goal's own group (no extra RPC needed). + +function parseIssueUrls(text) { + const raw = String(text || '').match(/https:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/(?:issues|pull)\/\d+/gi) || []; + const seen = new Set(); + const issues = []; + for (const candidate of raw) { + const url = candidate.replace(/[),.;:\]}]+$/g, ''); + if (seen.has(url)) continue; + seen.add(url); + const m = url.match(/\/(?:issues|pull)\/(\d+)$/); + issues.push({ url, number: m ? Number(m[1]) : null }); + } + return issues; +} + +function issueStatusLabel(status) { + if (status === 'done') return t('issueDone'); + if (status === 'blocked') return t('issueBlocked'); + if (status === 'deferred') return t('issueDeferred'); + if (status === 'open') return t('issueOpen'); + return t('issuePending'); +} + +async function refreshGoalIssues(g, force = false) { + if (g.issuesLoading) return; + if (!force && g.issues && Date.now() - g.issuesAt < 60000) return; + g.issuesLoading = true; + try { + const res = await app.call('loopx.goalIssues', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + }); + g.issues = res && res.ok ? res : { issues: [], total: 0, done: 0, open: 0 }; + g.issuesAt = Date.now(); + } catch (err) { + if (!g.issues) g.issues = { issues: [], total: 0, done: 0, open: 0 }; + dbgUi('goalIssues:error', `${g.goalId} ${String(err && (err.message || err)).slice(0, 120)}`); + } finally { + g.issuesLoading = false; + renderGoal(g); + } +} + +const ISSUE_CHIP_LIMIT = 12; +const ISSUE_CHIP_LIMIT_RAIL = 6; + +// The objective may carry explicit issue URLs, a bare issues-list URL, or no +// URL at all. The todo projection (loopx.goalIssues) is the authoritative +// issue list for batch goals: intake writes one agent todo per issue. +function objectiveHasIssueSignal(text) { + const t2 = String(text || '').trim(); + return /https:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/(?:issues|pull)(?:\/\d+)?\/?/i.test(t2) + || /https:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/?$/i.test(t2); +} + +// opts.rail: the compact inline variant for 进行中 directory rows (fewer +// chips); opts.skipLoad: do not kick the lazy projection load (compact rows). +function buildIssueStrip(g, opts = {}) { + if (!objectiveHasIssueSignal(g.objective || '')) return null; + const objectiveUrls = parseIssueUrls(g.objective || ''); + const single = objectiveUrls.length === 1; + const chipLimit = opts.rail ? ISSUE_CHIP_LIMIT_RAIL : ISSUE_CHIP_LIMIT; + const projection = (g.issues && g.issues.issues) || []; + const byUrl = new Map(); + for (const issue of projection) if (issue.url) byUrl.set(issue.url, issue); + + // Chip rows: explicit objective URLs first; when the objective only names + // the issues list (batch), the projection IS the issue list. null = batch + // projection still loading. + let rows = null; + if (objectiveUrls.length) { + rows = objectiveUrls.map((u) => ({ url: u.url, number: u.number, info: byUrl.get(u.url) || null })); + for (const issue of projection) { + if (!objectiveUrls.some((u) => u.url === issue.url)) { + rows.push({ url: issue.url, number: issue.number, info: issue }); + } + } + } else if (g.issues) { + rows = projection.map((issue) => ({ url: issue.url, number: issue.number, info: issue })); + } + + const strip = document.createElement('div'); + strip.className = 'goal__issues' + (opts.rail ? ' goal__issues--rail' : ''); + if (!single) { + const head = document.createElement('span'); + head.className = 'goal__issues-head'; + if (rows) { + const doneCount = rows.filter((r) => r.info && r.info.done).length; + head.textContent = t('issuesProgress', doneCount, rows.length); + } else { + head.textContent = t('issuesProgress', '…', '…'); + } + strip.appendChild(head); + } + + if (rows === null) { + const pending = document.createElement('span'); + pending.className = 'issue-chip issue-chip--pending'; + pending.textContent = '…'; + strip.appendChild(pending); + } else { + rows.slice(0, chipLimit).forEach((row) => { + const status = single + ? (goalGroup(g) === 'done' ? 'done' : 'open') + : (row.info ? row.info.status : 'pending'); + const chip = document.createElement('a'); + chip.className = `issue-chip issue-chip--${status}`; + chip.href = row.url; + chip.target = '_blank'; + chip.rel = 'noreferrer'; + chip.textContent = `#${row.number}`; + const label = row.info && row.info.title ? `#${row.number} ${row.info.title}` : `#${row.number}`; + chip.title = `${label} · ${issueStatusLabel(status)}`; + // The chip is a link, not a card/row activation. + chip.onclick = (ev) => ev.stopPropagation(); + strip.appendChild(chip); + }); + if (rows.length > chipLimit) { + const more = document.createElement('span'); + more.className = 'issue-chip issue-chip--more'; + const rest = rows.length - chipLimit; + more.textContent = t('moreIssues', rest); + more.title = `${rest} more`; + strip.appendChild(more); + } + } + + // Batch goals lazy-load their todo projection, refreshing at most once per + // 60s window per goal (board re-renders kick expired caches naturally). + if (!opts.skipLoad && !single && !g.issuesLoading && (!g.issues || Date.now() - g.issuesAt >= 60000)) { + refreshGoalIssues(g); + } + return strip; +} + +// A titled card section: label + dashed divider, so each human-facing part +// (target / decision / progress) reads as its own block instead of one wall +// of text. +function buildGoalSection(labelText, extraClass = '') { + const sec = document.createElement('div'); + sec.className = `goal__section${extraClass ? ` ${extraClass}` : ''}`; + const label = document.createElement('div'); + label.className = 'goal__section-label'; + label.textContent = labelText; + sec.appendChild(label); + return sec; +} + +function buildGoalCard(g, compact = false) { + const group = goalGroup(g); + const el = document.createElement('div'); + el.className = 'goal' + (compact ? ' goal--terminal' : '') + + (group === 'review' ? ' goal--gated' : '') + + (S.activeGoalId === g.goalId ? ' is-selected' : ''); + el.id = `goal-${g.goalId}`; + el.setAttribute('role', 'button'); + el.setAttribute('aria-label', g.goalId); + // Archived cards are restore-only: no detail panel, no run controls. + if (!g.archived) el.onclick = () => openGoalDetails(g); + + const head = document.createElement('div'); + head.className = 'goal__head'; + const dot = document.createElement('span'); + dot.className = `dot dot--${group}`; + head.appendChild(dot); + const id = document.createElement('span'); + id.className = 'goal__id'; + id.textContent = goalDisplayName(g); + id.title = g.goalId; + head.appendChild(id); + if (group === 'review') { + const badge = document.createElement('span'); + badge.className = 'goal__gate-badge'; + // Real count once loaded; no phantom "1" while the todo list is pending. + // Only BLOCKING items count as pending decisions; informational todos + // stay visible only as a hover hint (they never render as cards). + const todos = Array.isArray(g.userTodos) ? g.userTodos : []; + const blockingN = todos.filter((td) => gateTodoInfo(td).isBlocking).length; + badge.textContent = blockingN > 0 ? t('gateCount', blockingN) : t('groupReview'); + if (todos.length > blockingN) { + badge.title = `${blockingN} ${t('gateGroupBlocking')} · ${todos.length - blockingN} ${t('gateGroupInfo')}`; + } + head.appendChild(badge); + } + head.appendChild(goalStatusChip(g)); + el.appendChild(head); + + if (!compact) { + // ── Sectioned card: every human-facing part gets its own titled block + // instead of one wall of text. ── + + // ① 修复目标:issue 胶囊行即叙述(计数+状态,标题在悬停)——无重复文字 + { + const sec = buildGoalSection(t('sectionTarget')); + const narrationText = goalNarration(g); + if (narrationText) { + const narration = document.createElement('div'); + narration.className = 'goal__reason' + (g.lastError ? ' goal__reason--err' : ''); + narration.textContent = narrationText; + narration.title = narrationText; // full text on hover + sec.appendChild(narration); + } + const issueStrip = buildIssueStrip(g); + if (issueStrip) sec.appendChild(issueStrip); + el.appendChild(sec); + } + + // ② 需要你决定:阻塞审批项(等你处理列的主内容) + if (group === 'review') { + const sec = buildGoalSection(t('sectionDecision'), 'goal__section--decision'); + if (Array.isArray(g.userTodos) && g.userTodos.length) { + sec.appendChild(buildGateItemsList(g)); + } else if (Array.isArray(g.userTodos)) { + // A gate with no approvable item would otherwise be a dead end — say + // so and offer the one action that can move it: run a turn. + const none = document.createElement('div'); + none.className = 'goal__gate-none'; + const gateWait = (g.last && g.last.ok !== false ? g.last.waitingOn : g.waitingOn) || null; + none.textContent = gateWait + ? (waitingLabel(gateWait) === t('groupReview') ? t('groupReview') : t('waitingOn', waitingLabel(gateWait))) + : t('gateEmptyHint'); + sec.appendChild(none); + if (!g.running) { + const runNext = document.createElement('button'); + runNext.type = 'button'; + runNext.className = 'btn btn--tiny btn--primary'; + runNext.textContent = t('runOnce'); + runNext.onclick = (ev) => { ev.stopPropagation(); executeRunOnce(g); }; + sec.appendChild(runNext); + } + } + el.appendChild(sec); + } + + // ③ 进度 / 结果:live stage line(规划中 / 修复中 / 待发布)或结论 + if (group !== 'review' && group !== 'archived') { + const sec = buildGoalSection(group === 'done' ? t('sectionResult') : t('sectionProgress')); + if (group === 'done') { + const conclusion = document.createElement('div'); + conclusion.className = 'goal__conclusion'; + conclusion.textContent = goalConclusion(g); + sec.appendChild(conclusion); + } else { + const stage = document.createElement('div'); + stage.className = 'goal__stage'; + stage.textContent = goalStageText(g); + sec.appendChild(stage); + } + el.appendChild(sec); + } + + // ④ 已归档:说明 + 恢复 + if (group === 'archived') { + const hint = document.createElement('div'); + hint.className = 'goal__conclusion'; + hint.textContent = t('archivedHint'); + el.appendChild(hint); + const restore = document.createElement('button'); + restore.type = 'button'; + restore.className = 'btn btn--tiny btn--primary'; + restore.textContent = t('restoreTask'); + restore.title = t('restoreTaskHint'); + restore.onclick = (ev) => { ev.stopPropagation(); restoreArchivedGoal(g, restore); }; + el.appendChild(restore); + } + } else { + // Terminal capsule (compact): flat and terse, no section chrome. + const narration = document.createElement('div'); + narration.className = 'goal__reason' + (g.lastError ? ' goal__reason--err' : ''); + narration.textContent = goalNarration(g); + narration.title = goalNarration(g); + el.appendChild(narration); + if (group === 'done') { + const conclusion = document.createElement('div'); + conclusion.className = 'goal__conclusion'; + conclusion.textContent = goalConclusion(g); + el.appendChild(conclusion); + } + if (group === 'archived') { + const hint = document.createElement('div'); + hint.className = 'goal__conclusion'; + hint.textContent = t('archivedHint'); + el.appendChild(hint); + const restore = document.createElement('button'); + restore.type = 'button'; + restore.className = 'btn btn--tiny btn--primary'; + restore.textContent = t('restoreTask'); + restore.title = t('restoreTaskHint'); + restore.onclick = (ev) => { ev.stopPropagation(); restoreArchivedGoal(g, restore); }; + el.appendChild(restore); + } + } + + if (g.running || g.currentActivity) { + const activity = document.createElement('div'); + activity.className = 'goal__activity' + (g.running ? ' goal__activity--live' : ''); + const pulse = document.createElement('span'); + pulse.className = 'goal__activity-dot'; + const text = document.createElement('span'); + text.className = 'goal__activity-text'; + text.dataset.goal = g.goalId; + text.textContent = g.currentActivity || t('activityStarting'); + activity.append(pulse, text); + el.appendChild(activity); + } + + const meta = document.createElement('div'); + meta.className = 'goal__meta'; + if (g.agentId) { + const agent = document.createElement('span'); + agent.textContent = g.agentId; + meta.appendChild(agent); + } + if (meta.children.length) el.appendChild(meta); + // Lifecycle actions on the card itself — more direct than a hidden panel. + // Archived cards get the dedicated 恢复 button instead. + if (group !== 'archived') el.appendChild(buildGoalActions(g, false)); + return el; +} + +function renderGoalDetails(g) { + const panel = document.getElementById('goal-detail-panel'); + if (panel.hidden || S.activeGoalId !== g.goalId) return; + if (!Array.isArray(g.activityLines)) g.activityLines = []; + if (typeof g.currentActivity !== 'string') g.currentActivity = ''; + const active = document.activeElement; + if (active && panel.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'SELECT')) return; + + const group = goalGroup(g); + document.getElementById('goal-detail-kicker').textContent = t(GROUP_I18N_KEY[group]); + const detailTitle = document.getElementById('goal-detail-title'); + detailTitle.textContent = goalDisplayName(g); + detailTitle.title = g.goalId; + const conclusionEl = document.getElementById('goal-detail-conclusion'); + if (conclusionEl) { + conclusionEl.textContent = isTerminal(g) ? goalConclusion(g) : ''; + conclusionEl.hidden = !isTerminal(g); + } + const stageEl = document.getElementById('goal-detail-stage'); + if (stageEl) { + const stageText = goalStageText(g); + stageEl.textContent = stageText || ''; + stageEl.hidden = !stageText; + } + const body = document.getElementById('goal-detail-body'); + + // The panel is the LOG and nothing else: gate items already live on the + // review column cards (with their action buttons), so repeating them here + // would just duplicate what the user is acting on. The stream is appended + // to incrementally while visible (recordGoalActivity / tick / + // upsertGoalStream); rebuilding it on every state change reset the scroll + // position — rebuild only when the panel was closed, the goal switched, or + // the run reset its lines. + let stream = body.querySelector('.activity-stream'); + const fresh = !stream || stream.dataset.goal !== g.goalId + || stream.children.length > g.activityLines.length; + + if (fresh) { + body.replaceChildren(); + stream = document.createElement('div'); + stream.className = 'activity-stream activity-stream--panel'; + stream.dataset.goal = g.goalId; + if (g.activityLines.length > 0) { + for (const entry of g.activityLines) stream.appendChild(activityLineElement(entry)); + } else { + const empty = document.createElement('div'); + empty.className = 'activity-empty'; + empty.textContent = g.running ? t('activityStarting') : t('activityEmpty'); + stream.appendChild(empty); + } + body.appendChild(stream); + // Land on the latest SYNCHRONOUSLY so no frame paints at the top, then + // settle once more after the next frame for any late layout shifts. + body.scrollTop = body.scrollHeight; + updateLogBottomBtn(); + requestAnimationFrame(() => { + for (const pre of stream.querySelectorAll('.activity-prompt--think pre')) { + pre.scrollTop = pre.scrollHeight; + } + body.scrollTop = body.scrollHeight; + updateLogBottomBtn(); + }); + return; + } + + // In-sync stream: append only the missing lines. + const follow = streamAtTail(stream); + while (stream.children.length < g.activityLines.length) { + const row = activityLineElement(g.activityLines[stream.children.length]); + stream.appendChild(row); + const pre = row.querySelector('.activity-prompt--think pre'); + if (pre) pre.scrollTop = pre.scrollHeight; + } + if (follow) streamFollowTail(stream); +} + +// The panel's scroll container is the body, not the stream: the stream grows +// with its content (block flow), so scrollHeight lives on its parent. +function streamScroller(stream) { + return stream.closest('.detail-panel__body') || stream; +} + +// The overflow can live on the body OR the stream itself; whichever one +// actually overflows is the element the user scrolls. +function streamTailTarget(stream) { + const sc = streamScroller(stream); + return stream.scrollHeight > stream.clientHeight + 2 ? stream : sc; +} + +// "Pinned to the tail" must be measured BEFORE the content mutates: after an +// append the new row's own height would otherwise masquerade as distance +// from the bottom and break the decision. The 8px window makes "at the +// bottom" exact — any real scroll-up disables the follow. +function streamAtTail(stream) { + const target = streamTailTarget(stream); + return target.scrollHeight - target.scrollTop - target.clientHeight < 8; +} + +let lastScrollTraceAt = 0; +// Chat-style follow: the log pins to the latest line ONLY when the user was +// at the bottom before the new content arrived. A reader scrolled up into +// history stays put — no yanking, no up/down bouncing. The throttled trace +// lands in debug-ui.log so a silent failure can be diagnosed from real values. +function streamFollowTail(stream) { + const sc = streamScroller(stream); + const target = streamTailTarget(stream); + const now = Date.now(); + if (now - lastScrollTraceAt > 5000) { + lastScrollTraceAt = now; + dbgUi('scrollTail', + `body sh=${sc.scrollHeight} ch=${sc.clientHeight} top=${sc.scrollTop} ` + + `stream sh=${stream.scrollHeight} ch=${stream.clientHeight} top=${stream.scrollTop} ` + + `target=${target === stream ? 'stream' : 'body'}`); + } + target.scrollTop = target.scrollHeight; + updateLogBottomBtn(); +} + +// Gate approval confirmation: full todo text + optional note, one deliberate +// click. The dialog is the only writer of todo complete from the UI. +// Publish-scope gates default to the console's PR flow (fork → push → PR). +function openApproveDialog(g, todo) { + const dlg = document.getElementById('dlg-approve'); + const isGate = todo.task_class === 'user_gate'; + const isPublish = isPublishTodo(todo); + const tokenOk = Boolean(String(S.config.githubToken || '').trim()); + const raw = todo.text || todo.title || todo.todo_id; + const hint = gateActionLabel(todo); + const approveText = document.getElementById('approve-text'); + approveText.replaceChildren(); + const lead = document.createElement('div'); + lead.textContent = isPublish ? t('approvePrHint') : (isGate ? t('approveGateHint') : t('todoDoneHint')); + approveText.appendChild(lead); + if (isPublish && !tokenOk) { + const warn = document.createElement('div'); + warn.textContent = t('approvePrNeedToken'); + approveText.appendChild(warn); + } + const explainKey = (isGate || isPublish) ? gateExplain(raw) : null; + if (explainKey) { + const explainEl = document.createElement('div'); + explainEl.className = 'approve-text__explain'; + explainEl.textContent = t(explainKey); + approveText.appendChild(explainEl); + } + if (isGate || isPublish) { + const summary = g.gateSummaries && g.gateSummaries.get(todo.todo_id); + if (summary && summary.status === 'done' && summary.text) { + const sum = document.createElement('div'); + sum.className = 'approve-text__summary'; + appendLabeledSummary(sum, summary.text); + approveText.appendChild(sum); + } else if (!summary || summary.status !== 'done') { + ensureGateSummary(g, todo); + } + } + const typeLine = document.createElement('div'); + typeLine.textContent = hint ? t('gateItemWithType', hint) : t('gateItemTitle'); + approveText.appendChild(typeLine); + // The decision dialog shows the todo's original wording inline (it IS the + // decision subject) — no 查看原文 collapsible for a single paragraph. + const rawEl = document.createElement('div'); + rawEl.className = 'gate-card__raw'; + rawEl.textContent = raw; + approveText.appendChild(rawEl); + dlg.querySelector('h2').textContent = isPublish + ? t('approvePrTitle') + : (isGate ? t('approveGateTitle') : t('todoDoneTitle')); + dlg.querySelector('button[value="approve"]').textContent = isPublish + ? t('approveAndPr') + : (isGate ? t('approveConfirm') : t('todoDoneConfirm')); + // Publish gates let the human CHOOSE: submit the PR (default) or approve + // without a PR — the choice is part of the guided flow, not a setting. + const approveOnlyBtn = dlg.querySelector('#btn-approve-only'); + if (isPublish) { + approveOnlyBtn.hidden = false; + approveOnlyBtn.textContent = t('approveOnly'); + approveOnlyBtn.onclick = () => { + dlg.returnValue = 'approve-only'; + dlg.close('approve-only'); + }; + } else { + approveOnlyBtn.hidden = true; + } + const noteInput = document.getElementById('approve-note'); + noteInput.value = ''; + dlg.returnValue = 'cancel'; + dlg.onclose = () => { + if (dlg.returnValue === 'approve') approveTodo(g, todo, noteInput.value.trim(), null, { publish: true }); + else if (dlg.returnValue === 'approve-only') approveTodo(g, todo, noteInput.value.trim(), null, { publish: false }); + }; + dlg.showModal(); +} + +// ── GitHub token settings ────────────────────────────────── +// The publish flow (fork → push → PR) authenticates with a fine-grained PAT. +// Saved through the regular config storage; nothing leaves the machine. +function openTokenDialog() { + const dlg = document.getElementById('dlg-token'); + document.getElementById('token-input').value = S.config.githubToken || ''; + document.getElementById('token-status').textContent = `${t('githubTokenStatus')}${ + S.config.githubLogin || (S.config.githubToken ? t('githubTokenSet') : t('githubTokenMissing'))}`; + dlg.showModal(); +} + +async function saveGitHubToken() { + const input = document.getElementById('token-input'); + const status = document.getElementById('token-status'); + const btn = document.getElementById('btn-token-save'); + const token = String(input.value || '').trim(); + if (!token) { + status.textContent = `${t('githubTokenStatus')}${t('githubTokenMissing')}`; + return; + } + btn.disabled = true; + try { + const res = await app.call('loopx.githubUser', { token }); + if (!res.ok || !res.login) throw new Error(res.error || t('githubTokenInvalid')); + S.config.githubToken = token; + S.config.githubLogin = res.login; + await saveConfig(); + status.textContent = t('githubTokenSaved', res.login); + log(`GitHub token saved (login=${res.login})`); + document.getElementById('dlg-token').close(); + // Gate cards show the credential state inline ("尚未登录 / 已配置 + // Token") — refresh the board so every card flips immediately; paused + // goals don't poll, so without this they would stay stale. + requestRender(true); + } catch (err) { + status.textContent = `${t('githubTokenInvalid')}:${String(err && err.message || err)}`; + } finally { + btn.disabled = false; + } +} + +async function clearGitHubToken() { + S.config.githubToken = ''; + S.config.githubLogin = ''; + await saveConfig(); + document.getElementById('token-input').value = ''; + document.getElementById('token-status').textContent = `${t('githubTokenStatus')}${t('githubTokenMissing')}`; + requestRender(true); // gate cards must reflect the cleared credential +} + +// One-click GitHub CLI login: the worker installs gh when missing (winget + +// system proxy), launches `gh auth login --web` (console window shows the +// one-time code, the browser completes the flow) and polls until done. +function appendGhLoginProgress(d) { + const el = document.getElementById('gh-login-progress'); + if (!el) return; + el.hidden = false; + el.textContent += `${d && d.line ? d.line : ''}\n`; + el.scrollTop = el.scrollHeight; +} + +async function runGhLogin() { + const btn = document.getElementById('btn-gh-login'); + const progress = document.getElementById('gh-login-progress'); + const status = document.getElementById('token-status'); + btn.disabled = true; + progress.hidden = false; + progress.textContent = ''; + try { + const res = await app.call('loopx.ghLogin', {}); + if (!res.ok) throw new Error(res.error || 'gh login failed'); + S.ghAvailable = true; + status.textContent = t('ghLoginDone', res.login || 'gh'); + log(`[gh] login complete (${res.login || '?'})`); + requestRender(true); // gate cards flip to the gh credential state + document.getElementById('dlg-token').close(); + } catch (err) { + const message = String(err && err.message || err); + status.textContent = `${t('ghLoginFailed')}:${message}`; + progress.textContent += `\n${message}\n`; + log(`[gh] login failed: ${message}`, true); + } finally { + btn.disabled = false; + } +} + +// ── one-click loopx state reset ───────────────────────────── +// Historical goals (pre one-repo-one-goal) pollute the board and dropdown; +// this wipes every loopx data location with a timestamped backup so the +// console starts from a clean slate. +function openResetConfirm() { + const dlg = document.getElementById('dlg-stop'); + document.getElementById('stop-title').textContent = t('resetLoopxTitle'); + document.getElementById('stop-text').textContent = t('resetLoopxText'); + dlg.querySelector('button[value="confirm"]').textContent = t('resetLoopxConfirm'); + dlg.returnValue = 'cancel'; + dlg.onclose = () => { + if (dlg.returnValue !== 'confirm') return; + resetLoopxState(); + }; + dlg.showModal(); +} + +async function resetLoopxState() { + setComposerBusy(true, t('resetLoopxWorking')); + try { + const res = await app.call('loopx.resetAll', { + projectDirs: [S.config.projectDir, ...Object.values(S.config.projectByGoal || {})].filter(Boolean), + }); + if (!res.ok) throw new Error(res.error || 'reset failed'); + // Clear every per-goal UI binding and the persisted logs. + for (const key of ['ownedGoals', 'monitorByGoal', 'agentByGoal', 'autoRunByGoal', 'modelByGoal', 'projectByGoal', 'stoppedByGoal', 'autoRunBeforeStop', 'agentSessionByGoal']) { + S.config[key] = {}; + } + S.persistedLogs = {}; + await saveConfig(); + try { await app.storage.set('logs', {}); } catch (_) {} + S.goals.clear(); + S.agentSessionByGoal.clear(); + S.activeGoalId = null; + document.getElementById('goal-detail-panel').hidden = true; + document.getElementById('detail-empty').hidden = false; + await refreshGoals(); + renderAllGoals(true); + setComposerBusy(false, ''); + setTaskFeedback(t('resetLoopxDone', res.backupDir || ''), 'ok'); + log(`loopx state reset (backup: ${res.backupDir || '?'})`); + } catch (err) { + const message = String(err && err.message || err); + setComposerBusy(false, ''); + setTaskFeedback(`${t('resetLoopxFailed')}: ${message}`, 'error'); + log(`loopx state reset failed: ${message}`, true); + } +} + +// Stopping is a deliberate, whole-task action: explain what it does before +// doing it, so the task never "vanishes" as a surprise. +function openStopConfirm(g) { + const dlg = document.getElementById('dlg-stop'); + document.getElementById('stop-title').textContent = t('stopConfirmTitle'); + document.getElementById('stop-text').textContent = t('stopConfirmText', goalDisplayName(g)); + dlg.querySelector('button[value="confirm"]').textContent = t('confirmStop'); + dlg.returnValue = 'cancel'; + dlg.onclose = () => { + if (dlg.returnValue !== 'confirm') return; + stopGoalTask(g); + }; + dlg.showModal(); +} + +function openDeleteConfirm(g) { + const dlg = document.getElementById('dlg-stop'); + document.getElementById('stop-title').textContent = t('deleteConfirmTitle'); + document.getElementById('stop-text').textContent = t('deleteConfirmText', goalDisplayName(g)); + dlg.querySelector('button[value="confirm"]').textContent = t('confirmDelete'); + dlg.returnValue = 'cancel'; + dlg.onclose = () => { + if (dlg.returnValue !== 'confirm') return; + deleteGoalTask(g); + }; + dlg.showModal(); +} + +// Delete a task: archive its runtime and drop it from the registry (the +// worker keeps a backup of the registry file). Irreversible from the board. +async function deleteGoalTask(g) { + const name = goalDisplayName(g); + try { + const res = await app.call('loopx.deleteGoal', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + }); + if (!res.ok) throw new Error(res.error || 'delete failed'); + log(`[${g.goalId}] ${t('taskDeleted', name)}`); + if (res.warning) log(`[${g.goalId}] ${res.warning}`, true); + for (const map of [ + S.config.ownedGoals, S.config.monitorByGoal, S.config.agentByGoal, + S.config.autoRunByGoal, S.config.modelByGoal, S.config.projectByGoal, + S.config.stoppedByGoal, S.config.autoRunBeforeStop, + S.config.agentSessionByGoal, + ]) { + if (map) delete map[g.goalId]; + } + await saveConfig(); + S.activeGoalId = null; + document.getElementById('goal-detail-panel').hidden = true; + await refreshGoals(); + saveLogs(); // the removed goal's persisted log drops out of the snapshot + renderAllGoals(true); + // Visible confirmation — a silent success reads as "nothing happened". + setTaskFeedback(t('taskDeleted', name), 'ok'); + } catch (err) { + const message = String(err && err.message || err); + log(`[${g.goalId}] delete failed: ${message}`, true); + recordGoalActivity(g, `${t('deleteTaskFailed')}: ${message}`, true); + // Failures must be visible even when the panel shows another goal. + setTaskFeedback(`${t('deleteTaskFailed')}: ${message}`, 'error'); + openAlertDialog(t('deleteTaskFailed'), `${name}:${message}`); + } +} + +// Restore an archived task: rebuild its registry entry and move its runtime +// back, then refresh — the card returns to the board paused (自动已关). +async function restoreArchivedGoal(g, button) { + if (g.restoring) return; + g.restoring = true; + if (button) button.disabled = true; + const name = goalDisplayName(g); + try { + const res = await app.call('loopx.restoreGoal', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId) || S.config.projectDir, + goalId: g.goalId, + archiveDir: g.archiveDir || null, + }); + if (!res.ok) throw new Error(res.error || 'restore failed'); + log(`[${g.goalId}] ${t('restoreDone', name)}`); + setTaskFeedback(t('restoreDone', name), 'ok'); + await refreshGoals(); + } catch (err) { + const message = String(err?.message || err); + log(`[${g.goalId}] restore failed: ${message}`, true); + setTaskFeedback(`${t('restoreFailed')}: ${message}`, 'error'); + openAlertDialog(t('restoreFailed'), `${name}:${message}`); + } finally { + g.restoring = false; + renderAllGoals(true); + } +} + +// Reusable single-confirm alert (dlg-stop with a neutral confirm label). +function openAlertDialog(title, text) { + const dlg = document.getElementById('dlg-stop'); + document.getElementById('stop-title').textContent = title; + document.getElementById('stop-text').textContent = text; + dlg.querySelector('button[value="confirm"]').textContent = t('close'); + dlg.returnValue = 'cancel'; + dlg.onclose = () => {}; + dlg.showModal(); +} + +function openGoalDetails(g) { + S.activeGoalId = g.goalId; + document.getElementById('goal-detail-panel').hidden = false; + document.getElementById('detail-empty').hidden = true; + renderGoalDetails(g); + renderAllGoals(true); // mark the selected card +} + +document.getElementById('btn-close-goal').addEventListener('click', () => { + S.activeGoalId = null; + document.getElementById('goal-detail-panel').hidden = true; + renderAllGoals(true); +}); + +// Fingerprint of everything the goal list displays except per-second +// countdown text (the countdown loop patches those spans in place). +function displayFingerprint() { + const parts = [ + String(S.goals.size), app.locale, + S.bootLoading ? 'loading' : 'ready', + S.intakeDraft ? `${S.intakeDraft.objective}|${S.intakeDraft.stage}` : '', + ]; + for (const g of S.goals.values()) { + parts.push([ + g.goalId, goalGroup(g), g.polling, g.running, g.stopped, g.monitoring, + g.autoRun, g.autoFailCount, + g.errorCount, g.unchangedCount, g.intervalMin.toFixed(2), + g.agents.join(','), g.agentId, + g.objective ?? '', + g.last ? decisionKey(g.last) : '', + g.last?.reason ?? '', g.last?.recommendedAction ?? '', + g.last?.state ?? g.state ?? '', g.last?.waitingOn ?? g.waitingOn ?? '', + g.lastError ?? '', + g.userTodos ? `${g.userTodos.length}|${g.userTodos.map((td) => td.todo_id).join(',')}` : '-', + g.lastRun ? `${g.lastRun.exitCode}|${g.lastRun.cancelled}|${g.lastRun.durationMs}` : '', + ].join('')); + } + return parts.join(''); +} + +let lastFingerprint = ''; +let lastMoreFingerprint = ''; + +// Rapid bursts of state changes (task-intake progress, goal refresh, first +// poll) would each rebuild the whole board; coalesce them into one repaint +// per animation frame so the board doesn't flash through intermediate DOMs. +let renderQueued = false; +let renderQueuedForce = false; +function requestRender(force = false) { + if (force) renderQueuedForce = true; + if (renderQueued) return; + renderQueued = true; + requestAnimationFrame(() => { + renderQueued = false; + const flushForce = renderQueuedForce; + renderQueuedForce = false; + renderAllGoals(flushForce); + }); +} + +// A render skipped because an input/select had focus must run once focus +// leaves the control — otherwise that repaint is silently dropped forever. +document.addEventListener('focusout', (e) => { + if (!S.renderPending) return; + const el = e.target; + if (el && (el.tagName === 'INPUT' || el.tagName === 'SELECT')) { + S.renderPending = false; + requestRender(false); + } +}); + +function renderAllGoals(force = false) { + if (BOOT_RENDER_COUNT < 12) { + BOOT_RENDER_COUNT += 1; + dbgUi('render', `#${BOOT_RENDER_COUNT} t=${bootMs()}ms force=${force} theme=${themeProbe()}`); + } + try { + const workspace = document.getElementById('workspace-root'); + const active = document.activeElement; + if (!force && active && workspace.contains(active) + && (active.tagName === 'INPUT' || active.tagName === 'SELECT')) { + // Never yank the DOM out from under the user's cursor; re-render on blur. + S.renderPending = true; + return; + } + const fp = displayFingerprint(); + if (!force && fp === lastFingerprint) return; + lastFingerprint = fp; + + // v3.2: the board shows only goals this console owns; other-host goals are + // listed separately and stay unmonitored until adopted. + const owned = []; + const other = []; + for (const g of S.goals.values()) (isOwnedGoal(g.goalId) ? owned : other).push(g); + // Every key goalGroup() can return must exist here: a missing key made + // buckets.get() return undefined, which crashed the parked spread (and + // silently killed every render before the refill/panel steps ran). + const buckets = new Map( + [...PRIMARY_GROUPS, 'backlog', 'paused', 'error', ...ARCHIVE_GROUPS].map((k) => [k, []]), + ); + for (const g of owned) buckets.get(goalGroup(g)).push(g); + + // ── 等你处理: a standalone decision column ──────────────── + const reviewGoals = buckets.get('review'); + document.getElementById('review-zone-title').textContent = t(GROUP_I18N_KEY.review); + document.getElementById('review-zone-count').textContent = String(reviewGoals.length); + document.getElementById('review-zone-sub').textContent = t(GROUP_SUB_KEY.review); + const reviewList = document.getElementById('review-list'); + reviewList.replaceChildren(); + if (reviewGoals.length === 0) { + const none = document.createElement('div'); + none.className = 'zone-empty' + (S.bootLoading ? ' zone-empty--loading' : ''); + none.textContent = S.bootLoading ? t('loadingGoals') : t('colEmpty'); + reviewList.appendChild(none); + } + for (const g of reviewGoals) reviewList.appendChild(buildGoalCard(g)); + + // ── 进行中 + log panel: ONE unit; the rail is its directory ── + const activeGoals = buckets.get('active'); + const pendingCount = S.intakeDraft ? 1 : 0; + document.getElementById('active-zone-title').textContent = t(GROUP_I18N_KEY.active); + document.getElementById('active-zone-count').textContent = String(activeGoals.length + pendingCount); + document.getElementById('active-zone-sub').textContent = t(GROUP_SUB_KEY.active); + const activeList = document.getElementById('active-list'); + activeList.replaceChildren(); + if (S.intakeDraft) activeList.appendChild(buildIntakeRow(S.intakeDraft)); + if (activeGoals.length === 0 && pendingCount === 0) { + const none = document.createElement('div'); + none.className = 'zone-empty' + (S.bootLoading ? ' zone-empty--loading' : ''); + none.textContent = S.bootLoading ? t('loadingGoals') : t('colEmpty'); + activeList.appendChild(none); + } + for (const g of activeGoals) activeList.appendChild(buildRunItem(g)); + // Parked tasks (aborted / errored) stay in the directory rail, dimmed, + // so they never "vanish" into an unnoticed corner chip. + const parked = [...buckets.get('paused'), ...buckets.get('error')]; + if (parked.length > 0) { + const divider = document.createElement('div'); + divider.className = 'run-rail__divider'; + divider.textContent = `${t('groupPaused')} · ${parked.length}`; + activeList.appendChild(divider); + for (const g of parked) activeList.appendChild(buildRunItem(g, true)); + } + + // Terminal goals and other-host goals hide behind quiet chips at the + // bottom of the review zone. + const moreGroups = []; + for (const key of ARCHIVE_GROUPS) { + if (buckets.get(key).length > 0) moreGroups.push({ key, goals: buckets.get(key) }); + } + if (other.length > 0) moreGroups.push({ key: 'other', goals: other }); + const moreArea = document.getElementById('more-area'); + // Heartbeat polls flip g.polling every ~1-2s, which changes the display + // fingerprint and rebuilds the whole board — including an EXPANDED archived + // panel, resetting its scroll and destroying hover/click targets mid-use + // ("点开归档后 UI 乱掉"). Rebuild the more footer only when its content + // actually changed; force renders (chip toggle) still rebuild. + const moreSig = moreGroups + .map((mg) => `${mg.key}:${mg.goals.map((goal) => goal.goalId).join(',')}`) + .join('|'); + if (force || moreSig !== lastMoreFingerprint) { + moreArea.replaceChildren(); + if (moreGroups.length > 0) moreArea.appendChild(buildMoreFooter(moreGroups)); + lastMoreFingerprint = moreSig; + } + + // Master-detail: the selected goal's panel rides inside the run unit. + const panel = document.getElementById('goal-detail-panel'); + const emptyHint = document.getElementById('detail-empty'); + if (S.activeGoalId) { + const activeGoal = S.goals.get(S.activeGoalId); + if (activeGoal) { + panel.hidden = false; + emptyHint.hidden = true; + renderGoalDetails(activeGoal); + } else { + S.activeGoalId = null; + panel.hidden = true; + emptyHint.hidden = false; + } + } else { + panel.hidden = true; + emptyHint.hidden = false; + } + refillComposerTarget(); + dbgUi('renderDone', `targetOpts=${document.getElementById('composer-target-menu')?.children.length ?? 'n/a'}`); + } catch (err) { + dbgUi('renderError', String(err && (err.stack || err.message) || err).slice(0, 500)); + } +} + +// One chip per hidden group; clicking toggles that group's compact cards. +function buildMoreFooter(groups) { + const footer = document.createElement('footer'); + footer.className = 'board-more'; + const chips = document.createElement('div'); + chips.className = 'board-more__chips'; + for (const group of groups) { + const open = S.moreOpen.has(group.key); + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'board-more__chip' + (open ? ' is-open' : ''); + const dot = document.createElement('span'); + dot.className = `dot dot--${group.key === 'other' ? 'backlog' : group.key}`; + const label = document.createElement('span'); + label.textContent = group.key === 'other' ? t('otherTasksTitle') : t(GROUP_I18N_KEY[group.key]); + const count = document.createElement('b'); + count.textContent = String(group.goals.length); + chip.append(dot, label, count); + chip.onclick = () => { + if (S.moreOpen.has(group.key)) S.moreOpen.delete(group.key); else S.moreOpen.add(group.key); + renderAllGoals(true); + }; + chips.appendChild(chip); + } + footer.appendChild(chips); + for (const group of groups) { + if (!S.moreOpen.has(group.key)) continue; + const panel = document.createElement('div'); + panel.className = 'board-more__panel'; + if (group.key === 'other') { + panel.appendChild(buildOtherGoalsRows(group.goals)); + } else { + for (const g of group.goals) panel.appendChild(buildGoalCard(g, true)); + } + footer.appendChild(panel); + } + return footer; +} + +// The panel header carries a live elapsed timer while the selected task +// runs — constant motion so the user knows the agent is alive. Per-card +// status chips (goalStatusChip) cover the "is it running normally?" answer. +function startCountdownLoop() { + if (S.countdownTimer) clearInterval(S.countdownTimer); + S.countdownTimer = setInterval(() => { + const activeGoal = S.activeGoalId ? S.goals.get(S.activeGoalId) : null; + const timer = document.getElementById('goal-detail-timer'); + if (timer) { + timer.textContent = (activeGoal && activeGoal.running) + ? t('elapsedLabel', fmtCountdown(Date.now() - activeGoal.runStartedAt)) + : ''; + } + }, 1000); +} + +// ── turn execution (host agent) ─────────────────────────── +// Turns run on BitFun's own agent (app.agent.run): the worker composes the +// prompt (loopx heartbeat-prompt + repo binding), the host executes it in a +// hidden session, agent:event streams progress. No external CLI host and no +// user-facing execution settings. One agent session per goal is reused so +// follow-up turns keep context. +const agentRuns = new Map(); // goalId -> { sessionId, turnId, startedAt, tick } + +// Host agent session ids survive restarts (config.agentSessionByGoal) so the +// next turn reuses the same hidden session and keeps the full prior context. +function agentSessionIdFor(goalId) { + return S.agentSessionByGoal.get(goalId) + || (S.config.agentSessionByGoal && S.config.agentSessionByGoal[goalId]) + || undefined; +} + +function rememberAgentSession(goalId, sessionId) { + S.agentSessionByGoal.set(goalId, sessionId); + S.config.agentSessionByGoal = S.config.agentSessionByGoal || {}; + if (S.config.agentSessionByGoal[goalId] !== sessionId) { + S.config.agentSessionByGoal[goalId] = sessionId; + saveConfig(); + } +} + +function forgetAgentSession(goalId) { + S.agentSessionByGoal.delete(goalId); + if (S.config.agentSessionByGoal && S.config.agentSessionByGoal[goalId]) { + delete S.config.agentSessionByGoal[goalId]; + saveConfig(); + } +} + +// A running turn that produces NO agent events for this long is treated as +// dead (host-side cancel lost the event pipe, webview hiccup, ...). The tick +// watchdog cancels it and lets the poll loop relaunch on the same session. +const STALL_TURN_MS = 5 * 60 * 1000; + +function stallRecover(g, run) { + const idleMin = Math.round((Date.now() - run.lastEventAt) / 60000); + const message = t('turnStalled', idleMin); + log(`[${g.goalId}] ${message}`, true); + recordGoalActivity(g, message, true); + try { app.agent.cancel(run.sessionId, run.turnId); } catch (_) {} + finishRun(g, { ok: false, error: message }); +} + +async function executeRunOnce(g) { + // Auto-run and the manual confirm dialog can race; whoever arrives second + // must not reset the live run's state or activity stream. + if (g.running || !isLiveGoal(g)) return; + if (!goalProjectDir(g.goalId)) { log(t('needProject'), true); return; } + if (!g.agentId) { log(`[${g.goalId}] ${t('needAgent')}`, true); return; } + g.running = true; + g.runStartedAt = Date.now(); + // The activity stream accumulates across runs (and restarts via the + // persisted log): the run boundary is the 正在启动 line below. Only the + // per-turn streaming buffers reset. + g.agentTextBuffer = ''; + g.thinkBuffer = ''; + recordGoalActivity(g, t('activityStarting')); + // Auto-focus: a task that starts running becomes the selected task, so its + // log streams into the right-hand panel without a click. + if (S.activeGoalId !== g.goalId) { + S.activeGoalId = g.goalId; + document.getElementById('goal-detail-panel').hidden = false; + document.getElementById('detail-empty').hidden = true; + } + // The liveness tick starts immediately: the panel keeps moving even while + // turnPrompt / agent.run are still in flight (or stuck), so a silent + // freeze is impossible — the elapsed clock visibly stops if it breaks. + const startedAt = g.runStartedAt; + const tick = setInterval(() => { + if (!isLiveGoal(g) || !g.running) return; + const run = agentRuns.get(g.goalId); + if (!run) return; + setGoalActivityTick(g, t('activityRunning', fmtCountdown(Date.now() - startedAt))); + // Stall watchdog: the host can cancel a turn WITHOUT the console ever + // seeing the completion event (webview hiccup / stale-run cleanup breaks + // the event pipe). Without this the card stays "running" forever and + // auto-run freezes. A turn with zero agent events for this long is dead + // for practical purposes: cancel it, report it, and let the poll loop + // re-decide — auto-run fires the next turn on the persisted session. + if (Date.now() - run.lastEventAt > STALL_TURN_MS) { + clearInterval(tick); + stallRecover(g, run); + } + }, 10000); + renderGoal(g); + log(`[${g.goalId}] turn started (agent=${g.agentId})`); + let requestedSessionId = null; + try { + const composed = await app.call('loopx.turnPrompt', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + agentId: g.agentId, + }); + if (!composed.ok) throw new Error(composed.error || 'turn prompt failed'); + dbgUi('turn:promptReady', `chars=${composed.prompt.length}`); + // The log shows what was sent to the agent — collapsed, expandable. + recordGoalActivity(g, t('activitySentPrompt', composed.prompt.length), false, 'prompt', composed.prompt); + // Reuse the goal's host session (persisted across restarts) so the agent + // continues with its full prior context instead of starting from scratch. + requestedSessionId = agentSessionIdFor(g.goalId); + const run = await app.agent.run(composed.prompt, { + sessionName: `bitfun-loopx · ${g.goalId}`, + sessionId: requestedSessionId || undefined, + enableTools: true, + model: modelForGoal(g.goalId), + }); + dbgUi('turn:agentStarted', `session=${run.sessionId} turn=${run.turnId} reused=${run.sessionId === requestedSessionId}`); + rememberAgentSession(g.goalId, run.sessionId); + agentRuns.set(g.goalId, { sessionId: run.sessionId, turnId: run.turnId, startedAt, tick, lastEventAt: Date.now() }); + } catch (err) { + const message = String(err?.message || err); + // A dead session id (host restarted or session data pruned) is retried + // once on a fresh session — cleared EVERYWHERE so the retry cannot reuse + // it. The retry path also stops this attempt's liveness tick, otherwise + // the interval would leak and double with the retry's own tick. + if (requestedSessionId && /session/i.test(message)) { + clearInterval(tick); + forgetAgentSession(g.goalId); + g.running = false; + return executeRunOnce(g); + } + log(`[${g.goalId}] turn error: ${message}`, true); + g.running = false; + recordGoalActivity(g, message, true); + renderGoal(g); + finishRun(g, { ok: false, error: message }); + } +} + +async function cancelGoalRun(g, button) { + const run = agentRuns.get(g.goalId); + if (!run) return; + if (button) button.disabled = true; + try { + await app.agent.cancel(run.sessionId, run.turnId); + } catch (err) { + log(`[${g.goalId}] cancel error: ${err.message || err}`, true); + if (button) button.disabled = false; + } +} + +// Terminal handling shared by completion/failure/cancel events. +function finishRun(g, { ok, cancelled = false, error = null }) { + const run = agentRuns.get(g.goalId); + if (run) clearInterval(run.tick); + agentRuns.delete(g.goalId); + g.running = false; + g.lastRun = { + exitCode: ok ? 0 : 1, + durationMs: run ? Date.now() - run.startedAt : 0, + status: cancelled ? 'cancelled' : (ok ? 'completed' : 'failed'), + ok, + cancelled, + }; + if (cancelled) { + // A manual cancel is an explicit "stop": disable the continuous loop + // instead of immediately relaunching what the user just killed. + if (g.autoRun) setAutoRun(g, false); + log(`[${g.goalId}] ${t('runCancelled')}`); + recordGoalActivity(g, t('runCancelled')); + } else if (ok) { + g.autoFailCount = 0; + log(`[${g.goalId}] turn completed`); + recordGoalActivity(g, t('activityCompleted')); + } else { + if (g.autoRun) { + g.autoFailCount += 1; + if (g.autoFailCount >= AUTO_RUN_FAIL_LIMIT) { + // Trip the breaker VISIBLY: flip the toggle off so the drawer shows + // reality and re-enabling it is the documented recovery path. + g.autoRun = false; + S.config.autoRunByGoal[g.goalId] = false; + saveConfig(); + log(t('autoRunDisabled', g.goalId), true); + try { + if (app.notifications?.system) app.notifications.system(t('title'), t('autoRunDisabled', g.goalId)); + } catch (_) {} + } + } + log(`[${g.goalId}] turn failed: ${error || '?'}`, true); + recordGoalActivity(g, error || t('activityFailed'), true); + } + requestRender(); + pollNow(g, { force: true }); // fresh decision even while hidden; auto-run re-fires from the poll +} + +function goalForAgentSession(sessionId) { + for (const [goalId, run] of agentRuns) { + if (run.sessionId === sessionId) return S.goals.get(goalId); + } + return null; +} + +// Compact progress narration from the agent event stream: the instructions +// sent to the agent, its streamed text, tool calls with brief args, and turn +// boundaries. Pure read/observe tools (Read, Grep, Glob, …) are the agent's +// eyes, not progress — skip them; repeats of the same verb collapse into one +// "×N" line via recordGoalActivity. +const QUIET_AGENT_TOOLS = new Set([ + 'read', 'grep', 'glob', 'ls', 'list', 'find', 'cat', 'search', + 'web_search', 'websearch', 'fetch', 'mcp', +]); + +// Chat-style streaming: the agent's reply and its reasoning each render as +// ONE live block updated in place as chunks arrive (like the host's chat). +// Chunks arrive at model speed — coalesce all buffer updates of a frame into +// ONE DOM write per goal per frame, so a burst of text-chunk events cannot +// re-layout the (capped) block dozens of times per second. +const streamPending = new Map(); // goalId -> { think, agent, raf } +function streamAgentText(g, text, think = false) { + if (!isLiveGoal(g) || !text) return; + const key = think ? 'thinkBuffer' : 'agentTextBuffer'; + if (typeof g[key] !== 'string') g[key] = ''; + g[key] += text; + let pend = streamPending.get(g.goalId); + if (!pend) { + pend = { think: null, agent: null, raf: false }; + streamPending.set(g.goalId, pend); + } + pend[think ? 'think' : 'agent'] = g[key]; + if (!pend.raf) { + pend.raf = true; + requestAnimationFrame(() => { + pend.raf = false; + if (!isLiveGoal(g)) { streamPending.delete(g.goalId); return; } + if (typeof pend.think === 'string') upsertGoalStream(g, 'think', pend.think); + if (typeof pend.agent === 'string') upsertGoalStream(g, 'agent', pend.agent); + pend.think = null; + pend.agent = null; + }); + } +} + +function flushAgentText(g) { + if (!isLiveGoal(g)) return; + const pend = streamPending.get(g.goalId); + for (const [key, kind] of [['agentTextBuffer', 'agent'], ['thinkBuffer', 'think']]) { + const latest = pend && typeof pend[kind] === 'string' ? pend[kind] : g[key]; + if (typeof latest === 'string' && latest.trim()) { + upsertGoalStream(g, kind, latest.trim()); + } + g[key] = ''; + } + if (pend) { pend.think = null; pend.agent = null; } +} + +// Create or update the single streaming block for a kind. Status/tool lines +// between chunks end the block: walk backwards past ticks and continue only +// the newest stream block of that kind; anything else starts a fresh one. +function upsertGoalStream(g, kind, text) { + const summary = String(text || '').replace(/\s+$/, ''); + if (!summary) return; + if (!Array.isArray(g.activityLines)) g.activityLines = []; + const now = new Date().toTimeString().slice(0, 8); + let lastIndex = -1; + for (let i = g.activityLines.length - 1; i >= 0; i -= 1) { + const e = g.activityLines[i]; + if (e.isTick) continue; + if (e.kind === kind && e.stream) lastIndex = i; + break; + } + const stream = document.querySelector(`.activity-stream[data-goal="${CSS.escape(g.goalId)}"]`); + const patchRow = (row) => { + if (kind === 'think') { + const pre = row.querySelector('.activity-prompt--think pre'); + if (pre) { + // Follow only while the user is near the block's bottom: scrolling + // up to read reasoning history must not be yanked back down. + const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48; + // In-place updates must respect the same DOM tail cap as creation — + // writing the full accumulated buffer here was the memory/layout bomb. + pre.textContent = cappedStreamText(summary, STREAM_DOM_CAPS.think); + if (nearBottom) pre.scrollTop = pre.scrollHeight; + } + } else { + const textEl = row.querySelector('.activity-stream__text'); + if (textEl) textEl.textContent = cappedStreamText(summary, STREAM_DOM_CAPS.agent); + } + const timeEl = row.querySelector('.activity-stream__time'); + if (timeEl) timeEl.textContent = now; + }; + if (lastIndex >= 0) { + const entry = g.activityLines[lastIndex]; + entry.line = summary; + entry.time = now; + if (stream && stream.children[lastIndex]) { + const follow = streamAtTail(stream); + patchRow(stream.children[lastIndex]); + if (follow) streamFollowTail(stream); + } + } else { + const entry = { time: now, line: summary, isErr: false, count: 1, kind, stream: true }; + g.activityLines.push(entry); + if (g.activityLines.length > 240) g.activityLines.splice(0, g.activityLines.length - 240); + if (stream) { + const follow = streamAtTail(stream); + const emptyEl = stream.querySelector('.activity-empty'); + if (emptyEl) emptyEl.remove(); + stream.appendChild(activityLineElement(entry)); + while (stream.children.length > 240) stream.removeChild(stream.firstChild); + if (follow) streamFollowTail(stream); + } else { + const panel = document.getElementById('goal-detail-panel'); + if (!panel.hidden && S.activeGoalId === g.goalId) renderGoalDetails(g); + } + } + // Model output is for the log panel only — the card's live line stays on + // tool/status progress (recordGoalActivity), never raw model prose. A + // "我需要用三句话…" style self-talk must not land on the human-facing card. + scheduleLogSave(); +} + +// Tool-event params stream in partial JSON fragments. Accumulate them per +// tool call and parse the running buffer; never surface raw fragments (they +// slice one command into garbage like "bfx" / "-d" / "eepseek-h"). +const toolParamsBuf = new Map(); // toolId -> accumulated raw params text +const toolLinesRecorded = new Map(); // toolId -> true (one line per tool call) + +function toolBriefFromText(text, final) { + const t = String(text || '').trim(); + if (!t) return ''; + try { + const p = JSON.parse(t); + if (!p || typeof p !== 'object') return ''; + if (Array.isArray(p)) { + // A single-element array mid-stream is just the first argv fragment — + // wait for the rest unless this is the final event. + if (!final && p.length < 2) return ''; + return p.map(String).join(' ').slice(0, 120); + } + const brief = p.command || p.cmd || p.file_path || p.filePath || p.path + || p.query || p.pattern || p.url || p.target_file + || (Array.isArray(p.args) ? p.args.map(String).join(' ') : '') + || (Array.isArray(p.arguments) ? p.arguments.map(String).join(' ') : '') + || ''; + return String(brief).slice(0, 120); + } catch (_) { + // Partial buffer. Never guess mid-stream (that produced garbage like + // "loop" or "bfx"); only make a best effort on the final event. + if (!final) return ''; + const cmdMatch = t.match(/"(?:cmd|command)"\s*:\s*"([^"]*)/); + if (cmdMatch && cmdMatch[1]) return cmdMatch[1].replace(/\\(.)/g, '$1').slice(0, 120); + const argsMatch = t.match(/"(?:args|arguments)"\s*:\s*\[\s*"([^"]*)/); + if (argsMatch && argsMatch[1]) return argsMatch[1].replace(/\\(.)/g, '$1').slice(0, 120); + return ''; + } +} + +function toolBrief(e, te, final) { + const raw = te.params ?? e.params; + if (raw == null) return ''; + const rawText = typeof raw === 'string' ? raw : JSON.stringify(raw); + if (!te.tool_id) return toolBriefFromText(rawText, final); + const buf = (toolParamsBuf.get(te.tool_id) || '') + rawText; + if (buf.length > 6000) toolParamsBuf.set(te.tool_id, buf.slice(-3000)); + else toolParamsBuf.set(te.tool_id, buf); + return toolBriefFromText(buf, final); +} + +function pruneToolMaps() { + if (toolLinesRecorded.size <= 300) return; + const keys = [...toolLinesRecorded.keys()].slice(0, 150); + for (const key of keys) { + toolLinesRecorded.delete(key); + toolParamsBuf.delete(key); + } +} + +app.agent.onEvent((e) => { + // Publish-time cause/solution runs are collected first: their sessions are + // one-shot analyses, not goal turns. + const analysisRun = analysisRuns.get(e.sessionId); + if (analysisRun) { + if (e.sourceEvent === 'text-chunk' && typeof e.text === 'string' && e.contentType !== 'thinking') { + analysisRun.buffer += e.text; + if (analysisRun.buffer.length > 8000) analysisRun.buffer = analysisRun.buffer.slice(-8000); + } else if (e.sourceEvent === 'dialog-turn-completed' + || e.sourceEvent === 'dialog-turn-failed' + || e.sourceEvent === 'dialog-turn-cancelled') { + clearTimeout(analysisRun.timer); + const parsed = extractLabeledLines(analysisRun.buffer, ['原因', '解决']); + analysisRun.resolve(parsed); + analysisRuns.delete(e.sessionId); + } + return; + } + // Chinese gate-summary runs are collected first: their sessions are not + // goal turns, so the normal goal event flow must not see them. + const summaryRun = summaryRuns.get(e.sessionId); + if (summaryRun) { + if (e.sourceEvent === 'text-chunk' && typeof e.text === 'string') { + // Reasoning chunks never enter the summary buffer — only the visible + // output stream is a candidate for the three-line answer. + if (e.contentType !== 'thinking') { + summaryRun.buffer += e.text; + if (summaryRun.buffer.length > 8000) summaryRun.buffer = summaryRun.buffer.slice(-8000); + } + } else if (e.sourceEvent === 'dialog-turn-completed') { + const sg = S.goals.get(summaryRun.goalId); + if (sg && isLiveGoal(sg)) { + sg.gateSummaries.set(summaryRun.todoId, { + status: 'done', + // Parse the labeled three-line answer out of whatever the model + // emitted (reasoning walls included) — the card shows ONLY those + // three lines. + text: cleanGateSummary(String(summaryRun.buffer || '')), + }); + scheduleGateSummarySave(); + requestRender(true); + } + summaryRuns.delete(e.sessionId); + } else if (e.sourceEvent === 'dialog-turn-failed' || e.sourceEvent === 'dialog-turn-cancelled') { + const sg = S.goals.get(summaryRun.goalId); + if (sg) sg.gateSummaries.set(summaryRun.todoId, { status: 'failed' }); + summaryRuns.delete(e.sessionId); + } + return; + } + const g = goalForAgentSession(e.sessionId); + if (!g) return; + // Any event for the goal refreshes the stall watchdog's clock. + const liveRun = agentRuns.get(g.goalId); + if (liveRun) liveRun.lastEventAt = Date.now(); + if (e.sourceEvent === 'tool-event') { + // New bridge nests the tool payload under `toolEvent` (event_type + + // tool_name/tool_id fields); the legacy flat shape stays as a fallback. + const te = e.toolEvent || {}; + const name = te.effectiveToolName || te.effective_tool_name + || te.toolName || te.tool_name || e.toolName || e.tool_name || e.name; + const phase = te.event_type || te.phase || e.phase; + const done = phase === 'Completed' || phase === 'completed'; + if (name && !QUIET_AGENT_TOOLS.has(String(name).toLowerCase())) { + const brief = toolBrief(e, te, done); + if (te.tool_id) { + // One line per tool call. If params have not streamed enough yet to + // name the command, wait — a bare name with garbage fragments is + // worse than a line that arrives one event later. A completed call + // with nothing parseable falls back to the bare name. + if (toolLinesRecorded.has(te.tool_id)) return; + if (!brief && !done) return; + toolLinesRecorded.set(te.tool_id, true); + pruneToolMaps(); + recordGoalActivity(g, brief ? `${name}:${brief}` : String(name)); + } else { + recordGoalActivity(g, brief ? `${name}:${brief}` : String(name)); + } + } + } else if (e.sourceEvent === 'text-chunk') { + // Stream the model's visible output AND its reasoning (dimmed) — the + // user wants to see what the model produces, not just its tools. + if (typeof e.text === 'string') { + streamAgentText(g, e.text, e.contentType === 'thinking'); + } + } else if (e.sourceEvent === 'dialog-turn-completed') { + flushAgentText(g); + finishRun(g, { ok: true }); + } else if (e.sourceEvent === 'dialog-turn-failed') { + flushAgentText(g); + finishRun(g, { ok: false, error: String(e.error || e.message || 'turn failed') }); + } else if (e.sourceEvent === 'dialog-turn-cancelled') { + flushAgentText(g); + finishRun(g, { ok: false, cancelled: true }); + } +}); + +// ── bootstrap / detection / goals ───────────────────────── +function prefixLabel(p) { + if (!p) return ''; + if (Array.isArray(p)) return p.join(' '); + const base = (p.argv || []).join(' '); + return p.env && p.env.PYTHONPATH ? `${base} (PYTHONPATH=${p.env.PYTHONPATH})` : base; +} + +async function detect() { + const banner = document.getElementById('banner-nodetect'); + try { + S.detect = await app.call('loopx.detect', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + }); + } catch (err) { + S.detect = { found: false, probes: [{ error: String(err.message || err) }] }; + } + if (S.detect.found) { + banner.hidden = true; + document.getElementById('btn-vendor-loopx').hidden = true; + document.getElementById('btn-install-loopx').hidden = true; + // Persist the working prefix — and heal a stale one: detect probes the + // persisted prefix first, so if the winner differs, the persisted one is + // broken (e.g. venv removed) and every poll would fail while the banner + // says "detected". + const detectedJson = JSON.stringify(S.detect.argvPrefix); + if (!S.config.argvPrefix || JSON.stringify(S.config.argvPrefix) !== detectedJson) { + S.config.argvPrefix = S.detect.argvPrefix; + saveConfig(); + } + log(t('detected', `${prefixLabel(S.detect.argvPrefix)} (${S.detect.version || '?'})`)); + return true; + } + banner.hidden = false; + const detail = document.getElementById('probe-detail'); + detail.hidden = false; + detail.textContent = (S.detect.probes || []) + .map((p) => `${(p.argvPrefix || []).join(' ')} → ${p.ok ? p.version : p.error || 'failed'}`) + .join('\n'); + await renderLoopxMissing(); + return false; +} + +// ── universal loopx acquisition ──────────────────────────── +// loopx is missing on this machine. Preferred path: fetch its source into the +// user's stable vendor dir and run it via PYTHONPATH (loopx has no runtime +// dependencies — only Python >= 3.11 and git are required). The pip install +// button stays as a fallback. Prerequisites are probed and reported item by +// item, so a machine without Python/git gets a concrete hint instead of a +// silent failure. +async function renderLoopxMissing() { + const vendorBtn = document.getElementById('btn-vendor-loopx'); + const pipBtn = document.getElementById('btn-install-loopx'); + const hint = document.getElementById('prereq-hint'); + if (!vendorBtn || !pipBtn || !hint) return; + let prereqs = null; + try { prereqs = await app.call('loopx.checkPrereqs', {}); } catch (_) { prereqs = null; } + if (prereqs && prereqs.market) { + // Market edition: interpreters are forbidden by the sandbox, so the + // vendor path cannot run. Keep only the pip guidance button. + vendorBtn.hidden = true; + pipBtn.hidden = false; + hint.hidden = true; + return; + } + if (prereqs && prereqs.ready) { + vendorBtn.hidden = false; + pipBtn.hidden = false; + hint.hidden = true; + return; + } + // Not ready: name exactly what is missing; hide the buttons until fixed. + vendorBtn.hidden = true; + pipBtn.hidden = true; + hint.hidden = false; + const lines = []; + if (!prereqs) { + lines.push(t('prereqUnknown')); + } else { + if (!prereqs.python || !prereqs.python.ok) { + lines.push(prereqs.python && prereqs.python.found && prereqs.python.version + ? `${t('prereqNeedPython')}(检测到 ${prereqs.python.version})` + : t('prereqNeedPython')); + } + if (!prereqs.git || !prereqs.git.found) lines.push(t('prereqNeedGit')); + } + hint.textContent = lines.join('\n'); +} + +// One-click bootstrap: stream pip install progress into the banner, then +// re-detect and reload goals. +function appendInstallProgress(d) { + const el = document.getElementById('install-progress'); + if (!el) return; + el.hidden = false; + el.textContent += `${d && d.line ? d.line : ''}\n`; + el.scrollTop = el.scrollHeight; +} +app.on('worker:installLoopx:progress', appendInstallProgress); +app.on('worker:vendorLoopx:progress', appendInstallProgress); + +async function runInstallLoopx() { + const btn = document.getElementById('btn-install-loopx'); + const progress = document.getElementById('install-progress'); + btn.disabled = true; + btn.textContent = t('installingLoopx'); + progress.hidden = false; + progress.textContent = ''; + try { + const res = await app.call('loopx.installLoopx', {}); + if (!res.ok) throw new Error(res.error || 'install failed'); + progress.textContent += `\n${t('installDone')}\n`; + if (await detect()) await refreshGoals(); + } catch (err) { + progress.textContent += `\n${t('installFailed')}: ${err.message || err}\n`; + } finally { + btn.disabled = false; + btn.textContent = t('installLoopxBtn'); + } +} + +async function runVendorLoopx() { + const btn = document.getElementById('btn-vendor-loopx'); + const progress = document.getElementById('install-progress'); + btn.disabled = true; + btn.textContent = t('vendoringLoopx'); + progress.hidden = false; + progress.textContent = ''; + try { + const res = await app.call('loopx.ensureVendor', {}); + if (!res.ok) throw new Error(res.error || 'vendor failed'); + progress.textContent += `\n${t('vendorDone')}: loopx ${res.version || '?'}\n`; + // Persist the vendor checkout as the source dir so later detections keep + // using it (and it heals itself on the next poll). + if (res.srcDir && !S.config.srcDir) { + S.config.srcDir = res.srcDir; + saveConfig(); + } + if (await detect()) await refreshGoals(); + } catch (err) { + progress.textContent += `\n${t('vendorFailed')}: ${err.message || err}\n`; + } finally { + btn.disabled = false; + btn.textContent = t('vendorLoopxBtn'); + } +} + +async function refreshGoals() { + try { + const res = await app.call('loopx.listGoals', { + argvPrefix: S.config.argvPrefix, + projectDir: S.config.projectDir, + projectDirs: projectRegistryDirs(), + }); + const fresh = new Set(); + let bindingChanged = false; + for (const info of res.goals || []) { + fresh.add(info.goalId); + const existing = S.goals.get(info.goalId); + if (existing) { + existing.state = info.state ?? existing.state; + existing.waitingOn = info.waitingOn ?? existing.waitingOn; + existing.agents = info.agents?.length ? info.agents : existing.agents; + existing.objective = info.objective ?? existing.objective; + // A restored goal leaves the archived group; a goal that got archived + // elsewhere (another host / loopx maintenance) moves into it. + existing.archived = !!info.archived; + existing.archiveDir = info.archiveDir || existing.archiveDir || null; + if (!existing.archived) { + // Just restored (or still active): re-arm monitoring for owned + // goals unless the user explicitly stopped or switched it off. + existing.monitoring = isOwnedGoal(info.goalId) + ? (S.config.monitorByGoal[info.goalId] !== false && S.config.stoppedByGoal[info.goalId] !== true) + : existing.monitoring; + } + } else { + S.goals.set(info.goalId, newGoalState(info.goalId, info)); + } + // listGoals reports which project directory each goal lives in (clone + // cache discovery after a fresh import): bind it so polls/turns know + // the checkout without a re-clone. + if (info.projectDir && S.config.projectByGoal[info.goalId] !== info.projectDir) { + S.config.projectByGoal[info.goalId] = info.projectDir; + bindingChanged = true; + } + } + for (const goalId of [...S.goals.keys()]) { + if (!fresh.has(goalId)) S.goals.delete(goalId); + } + if (bindingChanged) await saveConfig(); + // The composer target dropdown must not depend on the board render + // completing: refresh it right here too. + refillComposerTarget(); + requestRender(true); + // Gate discovery for goals that never poll (paused / auto-run off): + // loopx may keep waiting_on=codex while an open user-lane todo (publish + // approval) sits pending. syncGateState is TTL-guarded, so this is one + // probe per goal per minute at most — polls keep monitored goals fresh. + for (const g of S.goals.values()) { + if (shouldTrackUserTodos(g)) syncGateState(g); + } + for (const g of S.goals.values()) { + if (g.monitoring && g.nextDueAt === 0) pollGoal(g); + } + rearmTimer(); + log(`goals refreshed: ${S.goals.size} (registry: ${res.registryPath})`); + } catch (err) { + log(`listGoals error: ${err.message || err}`, true); + } +} + +// ── toolbar wiring ──────────────────────────────────────── +// There is no header bar anymore: refresh is implicit (heartbeat + boot + +// retry), GitHub credentials are prompted by the publish flow itself +// (openTokenDialog), and per-task deletion lives on each goal card — the +// former top bar carried only the brand, so it was removed entirely. +document.getElementById('btn-token-save').addEventListener('click', saveGitHubToken); +document.getElementById('btn-token-clear').addEventListener('click', clearGitHubToken); +document.getElementById('btn-gh-login').addEventListener('click', runGhLogin); +app.on('worker:ghLogin:progress', appendGhLoginProgress); +// External guide links open in the system browser (sandboxed iframe cannot +// navigate top-level windows on its own). +document.querySelectorAll('.external-link').forEach((a) => { + a.addEventListener('click', (ev) => { + ev.preventDefault(); + try { + if (app.system && app.system.openExternal) app.system.openExternal(a.href); + else window.open(a.href, '_blank', 'noopener'); + } catch (_) { + window.open(a.href, '_blank', 'noopener'); + } + }); +}); +document.getElementById('btn-retry-detect').addEventListener('click', async () => { + if (await detect()) refreshGoals(); +}); +document.getElementById('btn-install-loopx').addEventListener('click', runInstallLoopx); +document.getElementById('btn-vendor-loopx').addEventListener('click', runVendorLoopx); + +document.getElementById('btn-copy-raw').addEventListener('click', async (e) => { + const text = document.getElementById('raw-body').textContent; + try { + if (app.clipboard?.writeText) await app.clipboard.writeText(text); + else await navigator.clipboard.writeText(text); + e.target.textContent = '✓'; + setTimeout(() => { e.target.textContent = t('copy'); }, 1200); + } catch (err) { + log(`copy failed: ${err.message || err}`, true); + } +}); + +// ── task intake ─────────────────────────────────────────── +// Flow: input → loopx.resolveIntake (read-only classify + expand issues-list) +// → confirmation sheet (issue checklist; new-vs-guide when goals exist) +// → loopx.taskIntake (event-driven) → auto-run takes over. +// Strict intake grammar (docs/product-spec.md): the only supported links are +// a single issue/PR, the issues list, and the repository home. Anything else +// is rejected with a specific message instead of being treated as the repo. +function taskInputKind(text) { + const urls = String(text || '').match(/https:\/\/github\.com\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+/gi) || []; + let issues = 0; + let lists = 0; + let repos = 0; + for (const url of urls) { + try { + const segments = new URL(url.replace(/[),.;:\]}]+$/g, '')).pathname.split('/').filter(Boolean); + const type = (segments[2] || '').toLowerCase(); + if (segments.length === 2) repos += 1; + else if (type === 'issues' && segments.length === 3) lists += 1; + else if (/^(issues|pull)$/.test(type) && segments.length === 4 && /^\d+$/.test(segments[3] || '')) issues += 1; + } catch (_) {} + } + if (lists || (repos && !issues)) return t('taskIssuesList'); + if (issues > 1) return t('taskIssues', issues); + if (issues === 1) return t('taskIssue'); + return ''; +} + +// First github.com URL in the text that does NOT fit the supported grammar +// (used to explain rejections precisely); null when none. +function firstUnsupportedGithubUrl(text) { + const urls = String(text || '').match(/https:\/\/github\.com\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+/gi) || []; + for (const url of urls) { + try { + const segments = new URL(url.replace(/[),.;:\]}]+$/g, '')).pathname.split('/').filter(Boolean); + const type = (segments[2] || '').toLowerCase(); + const supported = segments.length === 2 + || (type === 'issues' && segments.length === 3) + || (/^(issues|pull)$/.test(type) && segments.length === 4 && /^\d+$/.test(segments[3] || '')); + if (!supported) return url; + } catch (_) {} + } + return null; +} + +function setTaskFeedback(message, mode = '') { + const feedback = document.getElementById('task-feedback'); + feedback.textContent = message || ''; + feedback.hidden = !message; + feedback.className = `composer__feedback${mode ? ` composer__feedback--${mode}` : ''}`; +} + +function updateTaskKind() { + const input = document.getElementById('task-input'); + const kind = taskInputKind(input.value); + const badge = document.getElementById('task-kind'); + badge.textContent = kind; + badge.hidden = !kind; +} + +function resolveDefaultAgent() { + if (S.config.defaultAgentId) return S.config.defaultAgentId; + for (const goal of S.goals.values()) { + if (goal.agentId) return goal.agentId; + if (goal.agents.length) return goal.agents[0]; + } + return Object.values(S.config.agentByGoal || {}).find(Boolean) || ''; +} + +function setComposerBusy(busy, message = '') { + document.getElementById('task-input').disabled = busy; + document.getElementById('btn-create-task').disabled = busy; + setTaskFeedback(message, busy ? '' : undefined); +} + +// The confirmation sheet is the one deliberate stop before anything is +// written: it shows exactly which issues become todos and where they land. +function openIntakeSheet(resolved, objective, targetGoal = null) { + S.pendingIntake = { + resolved, + objective, + selected: new Set(resolved.issues.map((i) => i.url)), + guideGoalId: targetGoal && !isTerminal(targetGoal) ? targetGoal.goalId : null, + }; + const dlg = document.getElementById('dlg-intake'); + const isList = resolved.kind === 'issues-list'; + const hasIssues = resolved.issues.length > 0; + const guiding = Boolean(S.pendingIntake.guideGoalId); + + document.getElementById('intake-title').textContent = isList + ? t('intakeTitleList') + : (resolved.issues.length > 1 ? t('intakeTitleIssues', resolved.issues.length) + : (resolved.issues.length === 1 ? t('intakeTitleIssue') : t('intakeTitleGoal'))); + + const summary = document.getElementById('intake-summary'); + if (isList) { + summary.textContent = t('intakeSummaryList', resolved.repo || '?', resolved.issues.length) + + (resolved.truncated ? ` ${t('intakeTruncated', resolved.issues.length)}` : ''); + } else if (resolved.issues.length > 1) summary.textContent = t('intakeSummaryIssues', resolved.repo || '?'); + else if (guiding && !hasIssues) summary.textContent = t('intakeSummaryGoal'); + else summary.textContent = objective; + if (guiding) { + const targetG = S.goals.get(S.pendingIntake.guideGoalId); + summary.textContent += `\n${t('guideTargetNote', targetG ? goalDisplayName(targetG) : S.pendingIntake.guideGoalId)}`; + } + if (resolved.autoClone) { + summary.textContent += `\n${t('intakeCloneNote', resolved.repo || '?')}`; + } else if (resolved.reuseDir) { + summary.textContent += `\n${t('intakeReuseNote', resolved.repo || '?')}`; + } + if (resolved.fellBackFromCheckout) { + summary.textContent += `\n${t('taskCloneOtherRepo', resolved.repo || '?', resolved.fellBackFromCheckout)}`; + } + // New tasks carry pre-granted write scope (this confirmation IS the + // consent); only publish/PR decisions still gate later. + if (resolved.repo && !guiding) { + summary.textContent += `\n${t('intakeWriteNote')}`; + } + + // issue checklist (only for multi/list intake; single issue needs no picking) + const listEl = document.getElementById('intake-issues'); + listEl.replaceChildren(); + listEl.hidden = !hasIssues || resolved.issues.length < 2; + const bar = document.getElementById('intake-selectbar'); + bar.hidden = listEl.hidden; + document.getElementById('intake-select-all').checked = true; + if (!listEl.hidden) { + for (const issue of resolved.issues) { + const row = document.createElement('label'); + row.className = 'intake-issue'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = true; + cb.onchange = () => { + if (cb.checked) S.pendingIntake.selected.add(issue.url); + else S.pendingIntake.selected.delete(issue.url); + updateIntakeCount(); + }; + const num = document.createElement('span'); + num.className = 'intake-issue__num'; + num.textContent = `#${issue.number}`; + const title = document.createElement('span'); + title.className = 'intake-issue__title'; + title.textContent = issue.title === `#${issue.number}` ? issue.url : issue.title; + title.title = issue.url; + row.append(cb, num, title); + // Image-bearing issues get a marker so the user can spot them at a glance. + if (issue.hasImages) { + const badge = document.createElement('span'); + badge.className = 'intake-issue__img'; + badge.textContent = '🖼'; + badge.title = t('issueHasImages'); + row.appendChild(badge); + } + listEl.appendChild(row); + } + } + + // Conservative guard: issues whose key info lives in screenshots + a + // text-only model = warn instead of silently letting the agent guess. + const imageCount = resolved.issues.filter((issue) => issue.hasImages).length; + const visionWarn = document.getElementById('intake-vision-warn'); + if (imageCount > 0 && !modelSupportsVision()) { + visionWarn.hidden = false; + visionWarn.textContent = t('intakeVisionWarn', imageCount); + } else { + visionWarn.hidden = true; + } + + updateIntakeCount(); + dlg.returnValue = 'cancel'; + dlg.onclose = () => { + const pending = S.pendingIntake; + S.pendingIntake = null; + if (dlg.returnValue !== 'confirm' || !pending) { + setComposerBusy(false, ''); + return; + } + startTaskIntake(pending, pending.guideGoalId || null); + }; + dlg.showModal(); +} + +function updateIntakeCount() { + const pending = S.pendingIntake; + if (!pending) return; + const total = pending.resolved.issues.length; + const selected = total ? pending.resolved.issues.filter((i) => pending.selected.has(i.url)).length : 0; + const countEl = document.getElementById('intake-count'); + countEl.textContent = total >= 2 ? t('intakeSelectedCount', selected, total) : ''; + const confirm = document.getElementById('btn-intake-confirm'); + const guiding = Boolean(pending.guideGoalId); + if (guiding) { + confirm.textContent = t('intakeConfirmGuide'); + confirm.disabled = total >= 2 && selected === 0; + } else if (total >= 2) { + confirm.textContent = t('intakeConfirmIssues', selected); + confirm.disabled = selected === 0; + } else { + confirm.textContent = t('intakeConfirmNew'); + confirm.disabled = false; + } +} + +document.getElementById('intake-select-all').addEventListener('change', (e) => { + const pending = S.pendingIntake; + if (!pending) return; + pending.selected = e.target.checked ? new Set(pending.resolved.issues.map((i) => i.url)) : new Set(); + document.querySelectorAll('#intake-issues input[type="checkbox"]').forEach((cb) => { cb.checked = e.target.checked; }); + updateIntakeCount(); +}); + +function startTaskIntake(pending, guideGoalId) { + const { resolved, objective } = pending; + const issues = resolved.issues.filter((i) => pending.selected.has(i.url)) + .map((i) => ({ url: i.url, number: i.number, title: i.title })); + // Guidance todos are claimed by the target goal's own agent, not the + // new-task default — a mismatch would leave them unclaimable. + const agentId = guideGoalId + ? (S.goals.get(guideGoalId)?.agentId || resolveDefaultAgent()) + : resolveDefaultAgent(); + // Binding order: the guided goal's own checkout, then a reuse directory the + // resolver matched to this repository, then the global setting. Auto-clone + // only fires when none of those exists. + const projectDir = guideGoalId + ? goalProjectDir(guideGoalId) + : (resolved.reuseDir || (!resolved.bypassCheckout ? S.config.projectDir : null)); + S.intakeDraft = { objective, stage: guideGoalId ? t('taskCreating') : t('stageBootstrap') }; + setComposerBusy(true, t('taskCreating')); + renderAllGoals(true); + dbgUi('intake:call', `mode=${guideGoalId ? 'guide' : 'new'} goalId=${guideGoalId || '(new)'} issues=${issues.length} projectDir=${projectDir || '(none)'}`); + app.call('loopx.taskIntake', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir, + objective, + agentId, + mode: guideGoalId ? 'guide' : 'new', + goalId: guideGoalId, + autoClone: !guideGoalId && !projectDir && Boolean(resolved.autoClone), + issues: issues.length ? issues : null, + }).then((res) => { + dbgUi('intake:accepted', `started=${Boolean(res && res.started)}`); + if (res && res.ok === false) { + // Synchronous refusal (repo checks) — no done event will follow. + S.intakeDraft = null; + requestRender(true); + setComposerBusy(false, ''); + const message = res.code === 'repository_mismatch' + ? t('taskRepoMismatch', res.requestedRepo, res.projectRepo || '?') + : (res.code === 'multiple_repositories' ? t('taskMultipleRepos') : (res.error || 'task intake failed')); + setTaskFeedback(message, 'error'); + return; + } + if (!res || !res.started) throw new Error('task intake did not start'); + // progress + completion arrive on worker:taskIntake:* events + }).catch((err) => { + dbgUi('intake:rejected', String(err && err.message || err)); + S.intakeDraft = null; + requestRender(true); + setComposerBusy(false, ''); + setTaskFeedback(String(err.message || err), 'error'); + log(`task intake error: ${err.message || err}`, true); + }); +} + +app.on('worker:taskIntake:progress', (d) => { + if (!S.intakeDraft) return; + const stages = { + expand: t('stageExpand'), + clone: d.percent != null ? t('stageClonePercent', d.percent) : t('stageClone'), + bootstrap: t('stageBootstrap'), + register: t('stageRegister'), + plan: t('stagePlan'), + todos: d.total ? t('stageTodos', d.current || 0, d.total) : t('taskCreating'), + refresh: t('stageRefresh'), + }; + S.intakeDraft.stage = stages[d.stage] || t('taskCreating'); + setTaskFeedback(S.intakeDraft.stage); + // Patch the pending rail row's stage line in place — a full board rebuild + // per progress event (clone percent streams) is exactly the flicker we + // remove. + const live = document.querySelector('.run-item--pending .goal__activity-text'); + if (live) live.textContent = S.intakeDraft.stage; +}); + +app.on('worker:taskIntake:done', async (result) => { + const input = document.getElementById('task-input'); + // A goal that got created is a goal we manage — even if some todos failed. + // Treating partial failure as total would hide the goal, leave auto-run + // off, and invite a retry that mints a duplicate (uniqueGoalId suffixes). + if (!result.ok && !result.created) { + let message = result.error || 'task creation failed'; + if (result.code === 'repository_mismatch') { + message = t('taskRepoMismatch', result.requestedRepo, result.projectRepo || '?'); + } else if (result.code === 'multiple_repositories') { + message = t('taskMultipleRepos'); + } else if (result.code === 'repository_unverified') { + message = t('taskRepoUnverified', result.requestedRepo || '?'); + } + S.intakeDraft = null; + requestRender(true); + setComposerBusy(false, ''); + setTaskFeedback(message, 'error'); + log(`task intake: ${message}`, true); + return; + } + if (result.mode === 'new') { + const agentId = resolveDefaultAgent(); + S.config.defaultAgentId = agentId; + S.config.agentByGoal[result.goalId] = agentId; + S.config.monitorByGoal[result.goalId] = true; + S.config.autoRunByGoal[result.goalId] = true; + S.config.ownedGoals[result.goalId] = true; + if (result.projectDir) S.config.projectByGoal[result.goalId] = result.projectDir; + await saveConfig(); + } + input.value = ''; + updateTaskKind(); + setComposerBusy(false, ''); + const resultGoal = S.goals.get(result.goalId); + const resultName = goalDisplayName(resultGoal || { goalId: result.goalId, objective: S.intakeDraft?.objective || '' }); + const skipNote = result.skippedDuplicates > 0 ? ` ${t('skippedDuplicates', result.skippedDuplicates)}` : ''; + if (!result.ok) { + // Goal exists but some todos failed — adopt it, say so honestly. + setTaskFeedback(t('taskPartial', resultName, result.writtenOk ?? 0, result.error || '') + skipNote, 'error'); + log(`[${result.goalId}] task intake partial: ${result.error}`, true); + } else if (result.mode === 'guide') { + setTaskFeedback(t('guideStarted', resultName) + skipNote, 'ok'); + log(`[${result.goalId}] guidance written (${result.written.length} todos)`); + } else { + setTaskFeedback(t('taskStarted', resultName) + skipNote, 'ok'); + log(`[${result.goalId}] task created (${result.intakeKind}, ${result.written.length} todos)`); + } + if (S.intakeDraft) S.intakeDraft.stage = t('taskStageStarting'); + await refreshGoals(); + // The registry can lag the intake write: keep the pending row until the + // goal actually shows up, then swap it for the real row in ONE render — + // no "task briefly vanished" gap in the 进行中 column. + let goal = S.goals.get(result.goalId); + for (let retry = 0; !goal && retry < 6; retry += 1) { + await new Promise((resolve) => setTimeout(resolve, 700)); + await refreshGoals(); + goal = S.goals.get(result.goalId); + } + S.intakeDraft = null; + if (goal) { + if (result.mode === 'new') { + goal.agentId = S.config.agentByGoal[result.goalId] || goal.agentId; + goal.autoRun = true; + goal.autoFailCount = 0; + } + requestRender(true); + // refreshGoals already polled the goal; a should_run=true decision fires + // the first turn through maybeAutoRun — one launch path, no races. + pollNow(goal, { force: true }); + } else { + requestRender(true); + } +}); + +async function createTaskFromInput() { + const input = document.getElementById('task-input'); + const objective = input.value.trim(); + if (!objective) { input.focus(); return; } + dbgUi('createTask:start', `text=${objective.slice(0, 120)}`); + // Composer semantics: + // 新建任务 → a COMPLETELY independent new task (link → issue intake). + // 已有任务 → whatever the human typed (link or free text) is FEEDBACK + // injected into that task as guidance — no issue-list sheet, no new + // todos; the agent's next turns follow the human's expectations. + const explicitId = composerTargetValue(); + if (explicitId) { + const target = S.goals.get(explicitId); + if (target && !isTerminal(target)) { + startGuidance(target, objective); + return; + } + // The picked goal vanished (deleted): fall through to a fresh task. + setComposerTarget(''); + } + // Free text is NOT a goal type — while a task is running, it becomes + // mid-task guidance on the active/running goal. + if (!taskInputKind(objective)) { + const bad = firstUnsupportedGithubUrl(objective); + if (bad) { + dbgUi('createTask:unsupported', bad); + setTaskFeedback(t('taskUnsupportedPath', bad), 'error'); + return; + } + const target = guidanceTargetGoal(); + if (!target) { + const runningCount = [...S.goals.values()].filter((g) => g.running).length; + dbgUi('createTask:guidanceRejected', `running=${runningCount}`); + setTaskFeedback(runningCount > 1 ? t('guidancePickOne') : t('guidanceNoRunning'), 'error'); + return; + } + startGuidance(target, objective); + return; + } + if (!resolveDefaultAgent()) { + setTaskFeedback(t('taskNeedAgent'), 'error'); + return; + } + setComposerBusy(true, t('taskResolving')); + dbgUi('createTask:callingResolve', `projectDir=${S.config.projectDir || '(none)'}`); + let resolved; + try { + resolved = await app.call('loopx.resolveIntake', { + projectDir: S.config.projectDir, + projectDirs: projectRegistryDirs(), + objective, + }); + // The bound checkout is a different repository than the link: fall back + // to the clone-directory path (auto-clone or reuse) so one console can + // work across repositories without touching Settings. + if (!resolved.ok && resolved.code === 'repository_mismatch') { + const boundRepo = resolved.projectRepo; + dbgUi('createTask:mismatchFallback', `${resolved.requestedRepo} vs ${boundRepo}`); + resolved = await app.call('loopx.resolveIntake', { + projectDir: null, + projectDirs: Object.values(S.config.projectByGoal || {}).filter(Boolean), + objective, + }); + if (resolved.ok && resolved.repo && boundRepo) { + // The global checkout must not leak back into binding for THIS repo: + // bypassCheckout marks the fallback so reuse/clone decisions ignore it. + resolved.fellBackFromCheckout = boundRepo; + resolved.bypassCheckout = true; + log(`repo switch: new task targets ${resolved.repo} (checkout was ${boundRepo})`); + } + } + dbgUi('createTask:resolved', JSON.stringify({ ok: resolved.ok, code: resolved.code, kind: resolved.kind, reuseDir: resolved.reuseDir || null, autoClone: resolved.autoClone, issues: resolved.issues && resolved.issues.length })); + } catch (err) { + dbgUi('createTask:resolveError', String(err && err.message || err)); + setComposerBusy(false, ''); + setTaskFeedback(String(err.message || err), 'error'); + return; + } + if (!resolved.ok) { + setComposerBusy(false, ''); + if (resolved.code === 'repository_mismatch') { + setTaskFeedback(t('taskRepoMismatch', resolved.requestedRepo, resolved.projectRepo || '?'), 'error'); + } else if (resolved.code === 'multiple_repositories') { + setTaskFeedback(t('taskMultipleRepos'), 'error'); + } else if (resolved.code === 'repository_unverified') { + setTaskFeedback(t('taskRepoUnverified', resolved.requestedRepo || '?'), 'error'); + } else if (resolved.code === 'repository_not_found') { + setTaskFeedback(t('taskRepoNotFound', resolved.requestedRepo || '?'), 'error'); + } else if (resolved.code === 'repository_lookup_failed') { + setTaskFeedback(t('taskRepoLookupFailed'), 'error'); + } else if (resolved.code === 'unsupported_github_path') { + setTaskFeedback(t('taskUnsupportedPath', resolved.url || '?'), 'error'); + } else if (resolved.code === 'unsupported_input') { + setTaskFeedback(t('taskGoalUnsupported'), 'error'); + } else { + setTaskFeedback(resolved.error || 'intake failed', 'error'); + } + return; + } + if (resolved.kind === 'issues-list' && resolved.issues.length === 0) { + setComposerBusy(false, ''); + setTaskFeedback(t('intakeNoIssues'), 'error'); + return; + } + setTaskFeedback(''); + // 新建任务: the intake always creates a completely independent task — + // no same-repo merging, no target overrides. (An explicitly selected + // existing task never reaches this path: its input became guidance above.) + if (resolved.issues.length < 2) { + startTaskIntake({ resolved, objective, selected: new Set(resolved.issues.map((i) => i.url)) }, null); + return; + } + openIntakeSheet(resolved, objective, null); +} + +// Free text targets a RUNNING task as guidance. An explicit composer-target +// pick wins first; otherwise prefer the selected goal, then a single running +// goal; multiple running goals need a pick first. +function guidanceTargetGoal() { + const pickedId = composerTargetValue(); + if (pickedId) { + const picked = S.goals.get(pickedId); + if (picked && !isTerminal(picked)) return picked; + } + if (S.activeGoalId) { + const selected = S.goals.get(S.activeGoalId); + if (selected && selected.running) return selected; + } + const running = [...S.goals.values()].filter((g) => g.running); + return running.length === 1 ? running[0] : null; +} + +async function startGuidance(g, text) { + const input = document.getElementById('task-input'); + setComposerBusy(true, t('guidanceSending')); + dbgUi('guidance:start', `goal=${g.goalId}`); + try { + const res = await app.call('loopx.guideGoal', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + agentId: g.agentId || null, + text, + }); + if (!res.ok) throw new Error(res.error || 'guidance failed'); + input.value = ''; + updateTaskKind(); + setComposerBusy(false, ''); + setTaskFeedback(t('guidanceSent', goalDisplayName(g)), 'ok'); + recordGoalActivity(g, t('guidanceLine', text), false, 'agent'); + log(`[${g.goalId}] guidance sent (${text.length} chars)`); + dbgUi('guidance:done', `goal=${g.goalId} todoId=${res.todoId || ''}`); + pollNow(g, { force: true }); // fresh decision; auto-run picks the message up + } catch (err) { + const message = String(err && err.message || err); + dbgUi('guidance:error', message); + setComposerBusy(false, ''); + setTaskFeedback(message, 'error'); + log(`guidance error: ${message}`, true); + } +} + +document.getElementById('task-input').addEventListener('input', () => { + updateTaskKind(); + setTaskFeedback(''); +}); +document.getElementById('task-input').addEventListener('keydown', (event) => { + // Enter submits; Shift+Enter inserts a newline (default browser behavior). + if (event.key !== 'Enter' || event.shiftKey) return; + event.preventDefault(); + createTaskFromInput(); +}); +document.getElementById('btn-create-task').addEventListener('click', createTaskFromInput); +document.getElementById('composer-model').addEventListener('change', async () => { + S.config.defaultModel = document.getElementById('composer-model').value || 'auto'; + await saveConfig(); + log(t('modelChanged', S.config.defaultModel)); +}); +document.getElementById('composer-target-trigger').addEventListener('click', () => { + const menu = document.getElementById('composer-target-menu'); + if (!menu) return; + if (menu.hidden) refillComposerTarget(); + menu.hidden = !menu.hidden; +}); +document.addEventListener('click', (e) => { + const target = document.getElementById('composer-target'); + if (target && !target.contains(e.target)) closeComposerTargetMenu(); +}); + + +// Dialog cancel buttons are type=button (a submit-type Cancel placed first +// becomes the form's default button, so Enter in any dialog input would +// silently cancel). Close explicitly instead. +document.querySelectorAll('dialog .dlg-cancel').forEach((btn) => { + btn.addEventListener('click', () => { + const dlg = btn.closest('dialog'); + dlg.returnValue = 'cancel'; + dlg.close('cancel'); + }); +}); + +// ── column resize handles ────────────────────────────────── +// Both dividers are draggable: 等你处理/进行中 and 进行中目录/日志面板. +// A dragged column becomes FIXED width (flex: 0 0 Npx) — the others keep +// their equal share of the remaining space. Widths persist in config and +// re-apply on boot. +function applyLayoutWidths() { + const review = document.getElementById('review-zone'); + const rail = document.getElementById('run-rail'); + if (review && S.config.reviewZoneWidth > 0) { + review.style.flex = `0 0 ${S.config.reviewZoneWidth}px`; + } + if (rail && S.config.railWidth > 0) { + rail.style.flex = `0 0 ${S.config.railWidth}px`; + } +} + +function makeResizable(handle, target, { min, max, persistKey }) { + if (!handle || !target) return; + let dragging = false; + let startX = 0; + let startW = 0; + handle.addEventListener('pointerdown', (event) => { + dragging = true; + startX = event.clientX; + startW = target.getBoundingClientRect().width; + handle.classList.add('is-dragging'); + document.body.style.userSelect = 'none'; + try { handle.setPointerCapture(event.pointerId); } catch (_) {} + event.preventDefault(); + }); + handle.addEventListener('pointermove', (event) => { + if (!dragging) return; + const width = Math.min(max, Math.max(min, startW + (event.clientX - startX))); + // Fixed width: flex-grow must stop fighting the drag (with the equal + // three-column default, grow would re-widen the column immediately). + target.style.flex = `0 0 ${width}px`; + target.style.flexGrow = '0'; + target.style.flexShrink = '0'; + }); + const finish = () => { + if (!dragging) return; + dragging = false; + handle.classList.remove('is-dragging'); + document.body.style.userSelect = ''; + S.config[persistKey] = Math.round(target.getBoundingClientRect().width); + saveConfig(); + }; + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); +} + +makeResizable( + document.getElementById('resize-review'), + document.getElementById('review-zone'), + { min: 220, max: 560, persistKey: 'reviewZoneWidth' }, +); +makeResizable( + document.getElementById('resize-rail'), + document.getElementById('run-rail'), + { min: 180, max: 480, persistKey: 'railWidth' }, +); + +// ── one-click back to the log tail ───────────────────────── +// Mirrors the chat-style affordance: once the reader scrolls up, a downward +// arrow button appears; clicking it jumps to the bottom (and keeps following). +const logBottomBtn = document.getElementById('btn-log-bottom'); +const logBodyEl = document.getElementById('goal-detail-body'); +function updateLogBottomBtn() { + if (!logBottomBtn || !logBodyEl) return; + const nearBottom = logBodyEl.scrollHeight - logBodyEl.scrollTop - logBodyEl.clientHeight < 48; + logBottomBtn.hidden = nearBottom || logBodyEl.scrollHeight <= logBodyEl.clientHeight + 8; +} +logBodyEl.addEventListener('scroll', updateLogBottomBtn); +logBottomBtn.addEventListener('click', () => { + logBodyEl.scrollTop = logBodyEl.scrollHeight; + updateLogBottomBtn(); +}); + +// ── i18n ────────────────────────────────────────────────── +function applyI18n() { + document.querySelectorAll('[data-i18n]').forEach((el) => { + const key = el.getAttribute('data-i18n'); + const value = t(key); + if (typeof value !== 'string') return; + if (el.getAttribute('data-i18n-attr') === 'title') el.title = value; + else el.textContent = value; + }); + document.querySelectorAll('[data-i18n-title]').forEach((el) => { + const value = t(el.getAttribute('data-i18n-title')); + if (typeof value === 'string') el.title = value; + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { + const value = t(el.getAttribute('data-i18n-placeholder')); + if (typeof value === 'string') el.placeholder = value; + }); + updateTaskKind(); + // Dynamic text the static pass just clobbered: the intake sheet's + // count-driven confirm label. + if (S.pendingIntake) updateIntakeCount(); +} + +app.onLocaleChange((locale) => { + if (typeof locale === 'string') document.documentElement.setAttribute('lang', locale); + applyI18n(); + renderAllGoals(true); +}); + +// ── lifecycle ───────────────────────────────────────────── +// The host documents onActivate/onDeactivate but does not emit them yet; +// keep the hooks (harmless, future-proof) and add two real signals: +// visibilitychange for window minimise, IntersectionObserver for the +// scene-tab display:none toggle (SceneViewport hides inactive tabs via CSS). +app.onDeactivate(pauseHeartbeat); +app.onActivate(resumeHeartbeat); +let intersecting = true; +document.addEventListener('visibilitychange', () => { + if (document.hidden) pauseHeartbeat(); + else if (intersecting) resumeHeartbeat(); +}); +const visObserver = new IntersectionObserver((entries) => { + intersecting = entries[entries.length - 1].isIntersecting; + if (!intersecting) pauseHeartbeat(); + else if (!document.hidden) resumeHeartbeat(); +}); +visObserver.observe(document.body); + +// Unified teardown: when the console closes (tab closed, scene unmounted, or +// the whole app exits) everything scheduled or running here must stop with +// it — no host agent turn or timer outliving the console's lifetime. +function teardownConsole() { + if (S.timer) clearTimeout(S.timer); + if (S.countdownTimer) clearInterval(S.countdownTimer); + for (const run of agentRuns.values()) { + try { app.agent.cancel(run.sessionId, run.turnId); } catch (_) {} + } + agentRuns.clear(); + for (const run of summaryRuns.values()) { + try { app.agent.cancel(run.sessionId, run.turnId); } catch (_) {} + } + summaryRuns.clear(); +} +window.addEventListener('beforeunload', teardownConsole); +window.addEventListener('pagehide', teardownConsole); + +// ── boot ────────────────────────────────────────────────── +(async function boot() { + dbgUi('boot:start', `t=${bootMs()}ms readyState=${document.readyState} theme=${themeProbe()}`); + await loadConfig(); + applyLayoutWidths(); + dbgUi('boot:configLoaded', `t=${bootMs()}ms projectDir=${S.config.projectDir || '(none)'} theme=${themeProbe()}`); + try { + const catalog = await app.ai.getModels(); + if (Array.isArray(catalog)) S.modelCatalog = catalog; + dbgUi('boot:models', `t=${bootMs()}ms catalog=${S.modelCatalog.length}`); + } catch (err) { + dbgUi('boot:modelsError', String(err && err.message || err)); + } + syncComposerModel(); + applyI18n(); + dbgUi('boot:i18nApplied', `t=${bootMs()}ms`); + startCountdownLoop(); + // Detect (banner + prefix persistence) and goal loading run in parallel: + // listGoals resolves the invocation prefix on its own, so the board no + // longer waits ~1.4s behind the CLI probe before showing goals. + const detectedPromise = detect(); + const goalsPromise = refreshGoals(); + const detected = await detectedPromise; + dbgUi('boot:detected', `t=${bootMs()}ms found=${detected} theme=${themeProbe()}`); + await goalsPromise; + S.bootLoading = false; + // Opening the console never auto-resumes a previous task: everything boots + // paused (自动已关) and the user starts tasks explicitly with 继续. This + // also guarantees "UI shows not running ⇒ nothing runs" after a restart — + // no auto-run can fire until the user opts back in. + let pausedAny = false; + for (const g of S.goals.values()) { + if (g.autoRun) { + g.autoRun = false; + S.config.autoRunByGoal[g.goalId] = false; + pausedAny = true; + } + } + if (pausedAny) saveConfig(); + // Fill the composer target dropdown even before the first board paint. + refillComposerTarget(); + const paints = performance.getEntriesByType('paint') + .map((p) => `${p.name}@${Math.round(p.startTime)}ms`).join(' ') || '(no paint entries)'; + dbgUi('boot:done', `t=${bootMs()}ms goals=${S.goals.size} theme=${themeProbe()} paint=${paints}`); + requestRender(true); +})(); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js new file mode 100644 index 0000000000..c87b1a199f --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js @@ -0,0 +1,2767 @@ +// bitfun-loopx MiniApp — Worker (Node.js/Bun). +// Wraps the loopx CLI (`loopx --format json …`). The host heartbeat lives in +// ui.js; this worker is stateless per call except for the cached invocation +// prefix and the per-goal run-once in-flight registry. + +const { spawn } = require('child_process'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); + +// Invocation candidates, probed in order. `python -m loopx` does NOT work +// (loopx has no __main__.py) — the module form must target loopx.cli. +// The worker process may inherit a restricted PATH (the host app can be +// launched from an environment without the Python Scripts dir), so common +// absolute locations of the pip console-script are probed directly too. +// Absolute python.exe locations (robust against a restricted worker PATH). +function absolutePythonExes() { + const list = []; + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + const pyRoot = path.join(localAppData, 'Programs', 'Python'); + let versions = []; + try { + versions = fs.readdirSync(pyRoot).filter((name) => /^Python\d+$/.test(name)); + } catch (_) {} + for (const version of versions) { + const exe = path.join(pyRoot, version, 'python.exe'); + if (fs.existsSync(exe)) list.push(exe); + } + } else if (process.platform !== 'win32') { + // Common absolute interpreter locations so a restricted worker PATH + // cannot hide a usable Python on macOS/Linux. + for (const exe of [ + '/opt/homebrew/bin/python3', + '/usr/local/bin/python3', + '/usr/bin/python3', + '/opt/homebrew/bin/python', + '/usr/local/bin/python', + '/usr/bin/python', + ]) { + if (fs.existsSync(exe)) list.push(exe); + } + } + return list; +} + +// Interpreter argv prefixes for `-m loopx.cli`, in probe order. `py -3` is the +// Windows launcher; POSIX systems name the 3.x interpreter `python3` first. +function moduleInterpreterArgvs() { + return process.platform === 'win32' + ? [['python'], ['py', '-3']] + : [['python3'], ['python']]; +} + +// Bundled sidecar binary shipped with the desktop installer (build-time +// PyInstaller onefile; see scripts/build-loopx.mjs). The host passes the +// resources directory through BITFUN_RESOURCE_DIR. When present this binary +// needs no Python, no git and no network — it outranks every other candidate. +function bundledLoopxExes() { + const dir = process.env.BITFUN_RESOURCE_DIR; + if (!dir) return []; + const names = process.platform === 'win32' ? ['loopx.exe', 'loopx'] : ['loopx', 'loopx.exe']; + return names + .map((name) => path.join(dir, 'loopx', name)) + .filter((candidate) => fs.existsSync(candidate)); +} + +function candidatePrefixes(srcDir) { + const list = [ + ...bundledLoopxExes().map((exe) => ({ argv: [exe] })), + { argv: ['loopx'] }, + ]; + for (const argv of moduleInterpreterArgvs()) { + list.push({ argv: [...argv, '-m', 'loopx.cli'] }); + } + for (const exe of absolutePythonExes()) { + const loopxExe = path.join(path.dirname(exe), 'Scripts', 'loopx.exe'); + if (fs.existsSync(loopxExe)) list.push({ argv: [loopxExe] }); + } + // Source-checkout candidates. Both the user-configured srcDir and the + // console's own vendored checkout are valid; the vendor dir is probed even + // when srcDir is empty, so a previously-vendored loopx is always found + // without any configuration (loopx is pure stdlib, Python >= 3.11). + const srcDirs = [...new Set([srcDir, vendorLoopxDir()].filter(Boolean))]; + for (const dir of srcDirs) { + if (!fs.existsSync(path.join(dir, 'loopx', 'cli.py'))) continue; + for (const argv of moduleInterpreterArgvs()) { + list.push({ argv: [...argv, '-m', 'loopx.cli'], env: { PYTHONPATH: dir } }); + } + for (const exe of absolutePythonExes()) { + list.push({ argv: [exe, '-m', 'loopx.cli'], env: { PYTHONPATH: dir } }); + } + } + return list; +} + +const DEFAULT_TIMEOUT_MS = 180000; + +// PR identity markers — the countability contract: every PR created by this +// tool carries both keywords, searchable on GitHub with `"bitfun-loopx" in:title`. +const PR_TITLE_PREFIX = '[bitfun-loopx] '; +const PR_BODY_MARKER = 'Created by bitfun-loopx (BitFun built-in MiniApp).'; + +// ── debug trace (worker side) ───────────────────────────────── +// Written to <appdir>/debug-worker.log so host logs are not required. +const DEBUG_WORKER = []; +function dbgWorker(tag, detail) { + const line = `${new Date().toISOString()} ${tag} ${detail || ''}`; + DEBUG_WORKER.push(line); + if (DEBUG_WORKER.length > 200) DEBUG_WORKER.shift(); + try { + fs.writeFileSync(path.join(process.cwd(), 'debug-worker.log'), `${DEBUG_WORKER.join('\n')}\n`); + } catch (_) {} +} +dbgWorker('boot', `pid=${process.pid} runtime=${process.version}`); + +let cachedPrefix = null; // { argv, env } + +// loopx is a Python CLI; on zh-CN Windows its stdout defaults to GBK, which +// breaks both JSON.parse and any non-ASCII reason strings. Force UTF-8, and +// prepend (never clobber) PYTHONPATH from a source-checkout overlay. +function buildEnv(envOverlay) { + const env = { ...process.env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8' }; + if (envOverlay) { + for (const [key, value] of Object.entries(envOverlay)) { + if (key === 'PYTHONPATH' && env.PYTHONPATH) { + env.PYTHONPATH = value + path.delimiter + env.PYTHONPATH; + } else { + env[key] = value; + } + } + } + return env; +} + +// child.kill() only terminates the direct child; on Windows the interesting +// work (python → codex …) lives in grandchildren. Kill the whole tree: on +// POSIX the child is spawned detached (own process group), so signal the +// group; on Windows use taskkill /T. +function killTree(child) { + if (!child || child.killed || child.exitCode !== null) return; + if (process.platform === 'win32') { + try { + const tk = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }); + tk.on('error', () => child.kill()); + } catch (_) { + child.kill(); + } + } else { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (_) { + child.kill(); + } + } +} + +function makeLineSplitter(onLine) { + let buf = ''; + return { + push(text) { + buf += text; + let idx; + while ((idx = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, idx).replace(/\r$/, ''); + buf = buf.slice(idx + 1); + if (line.trim()) onLine(line); + } + }, + flush() { + if (buf.trim()) onLine(buf.trim()); + buf = ''; + }, + }; +} + +// onStderrLine streams progress (loopx logs to stderr while running; in +// --format json mode stdout carries exactly one JSON document at exit, so +// streaming stdout lines would only replay the final payload as noise). +function spawnLoopx(prefix, args, { timeoutMs = DEFAULT_TIMEOUT_MS, onStderrLine = null, onSpawned = null } = {}) { + const argv = Array.isArray(prefix) ? prefix : prefix.argv; + const envOverlay = Array.isArray(prefix) ? null : prefix.env; + return new Promise((resolve, reject) => { + const [cmd, ...prefixArgs] = argv; + const child = spawn(cmd, [...prefixArgs, ...args], { + shell: false, + windowsHide: true, + // POSIX: run the child in its own process group so killTree can signal + // the whole loopx tree on timeout (kill() alone would leak python/codex + // descendants). + detached: process.platform !== 'win32', + env: buildEnv(envOverlay), + }); + if (onSpawned) onSpawned(child); + let stdout = ''; + let stderr = ''; + // setEncoding routes chunks through a StringDecoder, so multibyte UTF-8 + // sequences split across 64KB pipe chunks decode correctly (quota status + // payloads run to hundreds of KB with CJK text). + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + const errSplitter = onStderrLine ? makeLineSplitter(onStderrLine) : null; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + killTree(child); + reject(new Error(`loopx timed out after ${timeoutMs}ms: ${args.join(' ')}`)); + }, timeoutMs); + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + if (errSplitter) errSplitter.push(chunk); + }); + child.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + child.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (errSplitter) errSplitter.flush(); + resolve({ code, stdout, stderr }); + }); + }); +} + +// loopx prints one JSON document on stdout in --format json mode; tolerate +// noise before the document and (best-effort) after it. +function parseJsonPayload(stdout) { + const text = stdout.trim(); + if (!text) return null; + try { + return JSON.parse(text); + } catch (_) {} + const start = text.indexOf('{'); + if (start >= 0) { + try { + return JSON.parse(text.slice(start)); + } catch (_) {} + const end = text.lastIndexOf('}'); + if (end > start) { + try { + return JSON.parse(text.slice(start, end + 1)); + } catch (_) {} + } + } + return null; +} + +// A prefix from the UI may be a plain argv array (settings input) or a +// {argv, env} object persisted from a previous detect. +function normalizePrefix(p) { + if (!p) return null; + if (Array.isArray(p)) return p.length > 0 ? { argv: p } : null; + if (typeof p === 'object' && Array.isArray(p.argv) && p.argv.length > 0) return p; + return null; +} + +async function resolvePrefix(argvPrefix, srcDir = null) { + const normalized = normalizePrefix(argvPrefix); + if (normalized) return normalized; + if (cachedPrefix) return cachedPrefix; + const detected = await detectLoopx(null, srcDir); + if (!detected.found) { + throw new Error( + 'loopx CLI not found. Install it (pip install -e <checkout>), ' + + 'or set the invocation command or source directory in Settings.' + ); + } + return normalizePrefix(detected.argvPrefix) || { argv: detected.argvPrefix }; +} + +function registryArgs(projectDir) { + if (!projectDir) return []; // loopx falls back to its global registry + return ['--registry', path.join(projectDir, '.loopx', 'registry.json')]; +} + +// loopx quota commands run a "public boundary" leak scan whose default root is +// the loopx source checkout itself; on a cold cache that scan alone can take +// over a minute. The monitoring gate never consumes scan hits, so point the +// scan at a small dedicated directory instead and keep the heartbeat fast. +function quotaScanRoot() { + const dir = path.join(process.cwd(), '.loopx-scan-root'); + try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {} + return dir; +} + +// ── auto-clone (direction C, docs/product-spec.md) ────────────── +// When no local checkout is selected, the target repository is cloned into the +// MiniApp's own data directory and the goal binds to that clone. Progress is +// streamed through the taskIntake event channel (stage 'clone'). +// +// The clone cache lives under the USER's stable .bitfun directory, NOT the +// per-instance appdata: deleting/re-importing the MiniApp would otherwise +// wipe every clone and force a fresh network clone. A stable cache also lets +// a fresh import rediscover the repos and their per-project registries. +// ── console data home ── +// The clone cache and the loopx vendor checkout live under the user's stable +// .bitfun directory, NOT the per-instance appdata: deleting/re-importing the +// MiniApp would otherwise wipe every clone and force a fresh network clone. +// A stable cache also lets a fresh import rediscover the repos and their +// per-project registries. +function consoleHome() { + return path.join(os.homedir(), '.bitfun', 'bitfun-loopx'); +} + +function cloneCacheRoot() { + return path.join(consoleHome(), 'repos'); +} + +function cloneTargetDir(repo) { + const safe = repo.replace(/[^A-Za-z0-9._-]/g, '-'); + return path.join(cloneCacheRoot(), safe); +} + +// ── loopx vendor copy (universal acquisition) ────────────── +// loopx has zero runtime dependencies (pure stdlib, requires Python >= 3.11), +// so a source checkout under the user's stable .bitfun directory runs directly +// via `python -m loopx.cli` + PYTHONPATH — no pip, no global install. Same +// stability rationale as the clone cache: re-importing the MiniApp must never +// wipe it, and a fresh import can rediscover it. +const LOOPX_REPO_URL = 'https://github.com/huangruiteng/loopx.git'; +// Version pin for the vendored checkout. loopx's CLI JSON contract IS this +// app's interface surface (AGENTS.md §1): pulling latest main risks semantic +// drift. Move this pin only together with a deliberate contract upgrade of +// the console (and update the installed-loopx expectations at the same time). +const LOOPX_VENDOR_REF = 'v0.2.13'; +const LOOPX_MIN_PYTHON = { major: 3, minor: 11 }; + +function vendorLoopxDir() { + return path.join(consoleHome(), 'vendor', 'loopx'); +} + +function parsePythonVersion(text) { + const m = String(text).match(/Python\s+(\d+)\.(\d+)/); + if (!m) return null; + return { + major: Number(m[1]), + minor: Number(m[2]), + raw: String(text).trim().split(/\r?\n/)[0] || '', + }; +} + +function pythonMeetsMinimum(version) { + return !!( + version + && (version.major > LOOPX_MIN_PYTHON.major + || (version.major === LOOPX_MIN_PYTHON.major && version.minor >= LOOPX_MIN_PYTHON.minor)) + ); +} + +// First interpreter that passes the version gate wins; otherwise report the +// first interpreter found at all so the UI can say "Python 3.9 detected, +// 3.11+ required" instead of a bare "not found". +async function probePython() { + const attempts = [ + ...absolutePythonExes().map((exe) => ({ prefix: [exe], args: ['--version'] })), + ]; + for (const argv of moduleInterpreterArgvs()) { + attempts.push({ prefix: argv, args: ['--version'] }); + } + let firstFound = null; + for (const attempt of attempts) { + let info; + try { + const { code, stdout, stderr } = await spawnLoopx({ argv: attempt.prefix }, attempt.args, { timeoutMs: 8000 }); + const version = parsePythonVersion(stdout + stderr); + info = { found: code === 0 && !!version, version: version ? version.raw : null, ok: code === 0 && pythonMeetsMinimum(version) }; + } catch (_) { + info = { found: false, version: null, ok: false }; + } + if (info.ok) return { ...info, exe: attempt.prefix.join(' ') }; + if (!firstFound && info.found) firstFound = { ...info, exe: attempt.prefix.join(' ') }; + } + return firstFound || { found: false, version: null, ok: false, exe: null }; +} + +async function probeGit() { + try { + const { code, stdout, stderr } = await spawnLoopx({ argv: ['git'] }, ['--version'], { timeoutMs: 8000 }); + return { found: code === 0, version: (stdout + stderr).trim().split(/\r?\n/)[0] || null }; + } catch (_) { + return { found: false, version: null }; + } +} + +function prereqErrorMessage(prereqs) { + const missing = []; + const python = prereqs && prereqs.python; + if (!python || !python.ok) { + missing.push(python && python.found && python.version + ? `Python ${python.version} is too old (>= ${LOOPX_MIN_PYTHON.major}.${LOOPX_MIN_PYTHON.minor} required)` + : 'Python >= 3.11 not found'); + } + if (!prereqs || !prereqs.git || !prereqs.git.found) missing.push('git not found'); + return `missing prerequisites: ${missing.join(', ')}`; +} + +async function checkPrereqs() { + const python = await probePython(); + const git = await probeGit(); + return { ready: !!(python.ok && git.found), python, git }; +} + +// ── commit marker ────────────────────────────────────────── +// Every commit the agent makes inside a console-managed clone carries the +// bitfun-loopx co-author trailer, so the tool's commits are countable on +// GitHub with the commit-search query "Co-authored-by: bitfun-loopx". +// Two layers: a commit-msg hook (hard guarantee, installed in the clone) +// plus a prompt instruction (the agent must not remove the trailer). +const COMMIT_TRAILER_KEY = 'Co-authored-by: bitfun-loopx'; +const COMMIT_TRAILER = `${COMMIT_TRAILER_KEY} <bitfun-loopx@users.noreply.github.com>`; +const COMMIT_MSG_HOOK = `#!/bin/sh +# bitfun-loopx marker: append the co-author trailer when absent. +TRAILER='${COMMIT_TRAILER}' +if ! grep -qF '${COMMIT_TRAILER_KEY}' "$1"; then + printf '\\n%s\\n' "$TRAILER" >> "$1" +fi +`; + +function ensureCommitTrailerHook(projectDir) { + // Only console-managed clones get a hook installed — never a repo the user + // selected on their own machine. + if (!projectDir || !String(projectDir).startsWith(cloneCacheRoot())) return false; + try { + const hooksDir = path.join(projectDir, '.git', 'hooks'); + const hookPath = path.join(hooksDir, 'commit-msg'); + if (fs.existsSync(hookPath)) { + const current = fs.readFileSync(hookPath, 'utf8'); + if (current.includes(COMMIT_TRAILER_KEY)) return true; + } + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(hookPath, COMMIT_MSG_HOOK, { encoding: 'utf8', mode: 0o755 }); + dbgWorker('commitHook:installed', projectDir); + return true; + } catch (err) { + dbgWorker('commitHook:error', `${err.message}`); + return false; + } +} + +function repoExistsOnGithub(repo) { + return new Promise((resolve, reject) => { + const req = https.get(`https://api.github.com/repos/${repo}`, { + headers: { + 'User-Agent': 'bitfun-loopx', + Accept: 'application/vnd.github+json', + }, + timeout: 30000, + }, (res) => { + res.resume(); + if (res.statusCode === 200) return resolve(true); + if (res.statusCode === 404) return resolve(false); + // Rate limits / server errors: let the clone attempt decide. + resolve(true); + }); + req.on('timeout', () => req.destroy(new Error('repository lookup timed out'))); + req.on('error', reject); + }); +} + +async function cloneRepository(repo, emit) { + const target = cloneTargetDir(repo); + if (fs.existsSync(path.join(target, '.git'))) { + emit({ detail: 'cached' }); + return target; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + const url = `https://github.com/${repo}.git`; + emit({ detail: 'start', url }); + await new Promise((resolve, reject) => { + const child = spawn('git', ['clone', '--progress', url, target], { windowsHide: true }); + let lastPercent = -1; + const onData = (chunk) => { + for (const rawLine of String(chunk).split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + const pct = line.match(/Receiving objects:\s+(\d+)%/); + if (pct && Number(pct[1]) !== lastPercent) { + lastPercent = Number(pct[1]); + emit({ percent: lastPercent }); + } else { + emit({ detail: line.slice(0, 140) }); + } + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('error', (err) => { + try { fs.rmSync(target, { recursive: true, force: true }); } catch (_) {} + reject(err); + }); + child.on('close', (code) => { + if (code === 0) resolve(); + else { + try { fs.rmSync(target, { recursive: true, force: true }); } catch (_) {} + reject(new Error(`git clone failed (exit ${code})`)); + } + }); + }); + return target; +} + +async function runJson(argvPrefix, projectDir, args, opts = {}) { + const prefix = await resolvePrefix(argvPrefix, opts.srcDir || null); + let result; + try { + result = await spawnLoopx(prefix, ['--format', 'json', ...registryArgs(projectDir), ...args], opts); + } catch (err) { + // A cached prefix can go stale (venv removed, PATH change): drop it so + // the next call re-probes instead of failing forever. + if (err && err.code === 'ENOENT' && prefix === cachedPrefix) cachedPrefix = null; + throw err; + } + const payload = parseJsonPayload(result.stdout); + return { result, payload, prefix }; +} + +async function detectLoopx(customPrefix, srcDir = null) { + const custom = normalizePrefix(customPrefix); + const candidates = custom ? [custom, ...candidatePrefixes(srcDir)] : candidatePrefixes(srcDir); + const probes = []; + for (const prefix of candidates) { + try { + const { code, stdout, stderr } = await spawnLoopx(prefix, ['--version'], { timeoutMs: 8000 }); + const version = (stdout + stderr).trim().split(/\r?\n/)[0] || ''; + const label = prefix.env ? [...prefix.argv, `(PYTHONPATH=${prefix.env.PYTHONPATH})`] : prefix.argv; + probes.push({ argvPrefix: label, ok: code === 0, version }); + if (code === 0) { + cachedPrefix = prefix; + return { found: true, argvPrefix: prefix, version, probes }; + } + } catch (err) { + dbgWorker('detect:probeError', `${prefix.argv.join(' ')}: ${String(err.message || err)}`); + probes.push({ argvPrefix: prefix.argv, ok: false, error: String(err.message || err) }); + } + } + return { found: false, argvPrefix: null, version: null, probes }; +} + +// Absolute python.exe locations (robust against a restricted worker PATH). +function pythonCandidates() { + const list = [...absolutePythonExes()]; + list.push(...(process.platform === 'win32' ? ['python'] : ['python3', 'python'])); + return list; +} + +function resolveRegistryPath(projectDir) { if (projectDir) return path.join(projectDir, '.loopx', 'registry.json'); + // Mirror the CLI: LOOPX_REGISTRY wins only if the file exists; otherwise + // loopx itself falls back to the global registry. + if (process.env.LOOPX_REGISTRY && fs.existsSync(process.env.LOOPX_REGISTRY)) { + return process.env.LOOPX_REGISTRY; + } + return path.join(os.homedir(), '.codex', 'loopx', 'registry.global.json'); +} + +function readRegistry(projectDir) { + const registryPath = resolveRegistryPath(projectDir); + try { + return { registryPath, registry: JSON.parse(fs.readFileSync(registryPath, 'utf8')) }; + } catch (_) { + return { registryPath, registry: null }; + } +} + +function normalizeScheduler(schedulerHint) { + if (!schedulerHint || typeof schedulerHint !== 'object') return null; + // Hot path: reset token + codex_app fallback. Cold path + // (--include-scheduler-detail): the local_scheduler block the heartbeat + // actually wants. + const cold = schedulerHint.cold_path_detail && schedulerHint.cold_path_detail.local_scheduler; + const codexApp = schedulerHint.codex_app || {}; + const local = cold || {}; + const resetPolicy = schedulerHint.reset_policy || {}; + const unchangedPoll = schedulerHint.unchanged_poll || {}; + const limits = unchangedPoll.limits || {}; + const afterLimits = unchangedPoll.after_limits || {}; + return { + recommendedIntervalMinutes: + local.recommended_interval_minutes ?? codexApp.recommended_interval_minutes ?? null, + maxIntervalMinutes: local.max_interval_minutes ?? codexApp.max_interval_minutes ?? null, + backoffMultiplier: local.unchanged_poll_backoff_multiplier ?? null, + unchangedPollLimit: local.unchanged_poll_limit ?? limits.local_scheduler ?? null, + afterLimit: local.after_limit ?? afterLimits.local_scheduler ?? null, + exampleProgression: local.example_progression_minutes ?? null, + resetToken: resetPolicy.reset_token ?? null, + cadenceClass: schedulerHint.cadence_class ?? null, + action: schedulerHint.action ?? null, + unchangedIdentityKeys: Array.isArray(schedulerHint.unchanged_identity_keys) + ? schedulerHint.unchanged_identity_keys + : null, + source: cold ? 'local_scheduler' : 'codex_app_fallback', + }; +} + +// Turn execution is delegated to the HOST's own agent (app.agent.run in the +// UI): loopx generates the per-turn task body via heartbeat-prompt, and this +// preamble binds it to the selected local checkout. No external CLI host +// (codex etc.) is involved — the user configures nothing. +function turnPreamble({ projectDir, goalId, agentId }) { + const registry = path.join(projectDir, '.loopx', 'registry.json'); + const moduleFallback = `${moduleInterpreterArgvs()[0].join(' ')} -m loopx.cli`; + const probeCmd = process.platform === 'win32' ? 'where loopx' : 'which loopx'; + const shellNote = process.platform === 'win32' + ? '环境说明:命令在 PowerShell 中运行,不是 bash。loopx 示例命令里以 --turn-instance-id "${LOOPX_TURN:?}" 结尾的写法是 bash 语法 —— 不要照抄;--turn-instance-id 是可选参数,直接省略。' + : '环境说明:命令在 bash 中运行。loopx 示例命令里的 ${LOOPX_TURN:?} 写法仅适用于 bash;--turn-instance-id 是可选参数,直接省略。'; + return [ + `你是 LoopX 目标 "${goalId}" 的执行 agent(agent_id: ${agentId})。`, + `仓库目录:${projectDir}`, + `LoopX 注册表:${registry} —— 调用 loopx 命令时始终带上 --registry "${registry}"。`, + `loopx CLI 已安装并位于 PATH(备用方式:${moduleFallback});每轮最多执行一次 "loopx --version"、"${probeCmd}" 之类的探测。`, + shellNote, + '规则:只在仓库目录内工作;不要强杀不是你启动的进程;', + '在结束本轮前,通过 loopx(todo complete / 证据记录)记录进度。', + '如果 loopx 命令失败,先读错误信息再修正调用方式 —— 同一命令不要盲目重试超过两次。', + `提交规范:每次 git commit 的提交信息末尾必须保留一行 "${COMMIT_TRAILER}"(仓库已安装 commit-msg 钩子会自动补上;不要删除这一行)。`, + '工作语言:所有面向用户的说明、总结与回复请使用中文。', + '', + ].join('\n'); +} + +// Strict intake grammar (product spec, docs/product-spec.md): the only +// supported GitHub links are +// - a single item: github.com/<owner>/<repo>/issues/<n> | /pull/<n> +// - the issues list: github.com/<owner>/<repo>/issues +// - the repository home: github.com/<owner>/<repo> +// Every other GitHub path (org pages, /settings, /pulls, /releases, /tree/..., +// /issues/new, commits, search, ...) is reported as unsupported instead of +// being silently treated as the repository. Extra text after the link stays +// allowed and becomes fix-instruction preamble. +function githubReferences(text) { + const refs = []; + const unsupported = []; + const seen = new Set(); + const pattern = /https:\/\/github\.com\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+/gi; + for (const match of String(text || '').matchAll(pattern)) { + const rawUrl = match[0].replace(/[),.;:\]}]+$/g, ''); + let parsed; + try { parsed = new URL(rawUrl); } catch (_) { continue; } + const segments = parsed.pathname.split('/').filter(Boolean); + if (segments.length < 2) { + unsupported.push({ url: rawUrl, reason: 'not_a_repository' }); + continue; + } + // GitHub owner/repo is case-insensitive; canonicalize to lowercase so + // dedup sets, repo-binding guards, and task_repository labels all agree. + const owner = segments[0].toLowerCase(); + const repoName = segments[1].replace(/\.git$/i, '').toLowerCase(); + if (!owner || !repoName) continue; + const type = (segments[2] || '').toLowerCase(); + const isIssueItem = /^(issues|pull)$/.test(type) + && segments.length === 4 + && /^\d+$/.test(segments[3] || ''); + const isIssuesList = type === 'issues' && segments.length === 3; + const isRepoRoot = segments.length === 2; + const repo = `${owner}/${repoName}`; + let kind = null; + let url = null; + let number = null; + if (isIssueItem) { + kind = type === 'issues' ? 'issue' : 'pr'; + number = Number(segments[3]); + url = `https://github.com/${repo}/${type}/${number}`; + } else if (isIssuesList) { + kind = 'issues-list'; + url = `https://github.com/${repo}/issues`; + } else if (isRepoRoot) { + kind = 'repository'; + url = `https://github.com/${repo}`; + } else { + unsupported.push({ + url: `https://github.com/${repo}/${segments.slice(2).join('/')}`, + reason: 'unsupported_path', + }); + continue; + } + if (seen.has(url)) continue; + seen.add(url); + refs.push({ url, repo, kind, number }); + } + return { refs, unsupported }; +} + +function normalizeGithubRemote(value) { + const text = String(value || '').trim(); + const match = text.match(/github\.com[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/i); + return match ? `${match[1]}/${match[2]}`.toLowerCase() : null; +} + +function projectGithubRepository(projectDir) { + if (!projectDir) return null; + try { + const config = fs.readFileSync(path.join(projectDir, '.git', 'config'), 'utf8'); + const origin = config.match(/\[remote\s+"origin"\][\s\S]*?\n\s*url\s*=\s*([^\r\n]+)/i); + if (origin) return normalizeGithubRemote(origin[1]); + const anyRemote = config.match(/\n\s*url\s*=\s*([^\r\n]+)/i); + return anyRemote ? normalizeGithubRemote(anyRemote[1]) : null; + } catch (_) { + return null; + } +} + +function uniqueGoalId(projectDir, objective, refs) { + // loopx enforces GLOBAL uniqueness of goal ids (a global route with the + // same id but a different repo rejects bootstrap). Union the project + // registry ids with the global registry ids so a fresh suffix is chosen + // instead of colliding with a stale global route. + const existing = new Set(); + for (const dir of [projectDir, null]) { + const { registry } = readRegistry(dir); + for (const goal of (registry && registry.goals) || []) { + const id = goal.goal_id || goal.id; + if (id) existing.add(id); + } + } + const issueRefs = refs.filter((ref) => ref.kind === 'issue' || ref.kind === 'pr'); + const listRef = refs.find((ref) => ref.kind === 'issues-list'); + // One task = one repository: the id carries BOTH owner and repo name, so + // two repositories with the same name under different owners can never + // collide into a suffix sibling goal (legacy ids were owner-less). + const owner = String((listRef || issueRefs[0] || {}).repo || '').split('/')[0].toLowerCase(); + const repoSlug = (ref) => (owner ? `${owner}-` : '') + String(ref.repo).split('/')[1]; + let base; + if (listRef) { + base = `${repoSlug(listRef)}-issues`; + } else if (issueRefs.length === 1) { + base = `${repoSlug(issueRefs[0])}-issue-${issueRefs[0].number}`; + } else if (issueRefs.length > 1) { + base = `${repoSlug(issueRefs[0])}-issues`; + } else { + base = String(objective) + .toLowerCase() + .replace(/https?:\/\/\S+/g, ' ') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 42); + } + if (!base) { + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + base = `task-${stamp}`; + } + // v3.2: miniapp-created goals carry the bfx- prefix so the board can tell + // them apart from goals created by other loopx hosts on this machine. + base = `bfx-${base}`; + let goalId = base; + let suffix = 2; + while (existing.has(goalId)) goalId = `${base}-${suffix++}`; + return goalId; +} + +function readGoalObjective(projectDir, goal) { + const stateFile = goal && goal.state_file; + if (!stateFile) return null; + const root = projectDir || goal.repo; + if (!root) return null; + try { + const content = fs.readFileSync(path.resolve(root, stateFile), 'utf8'); + const yaml = content.match(/^objective:\s*(.+)$/m); + if (yaml) { + const raw = yaml[1].trim(); + if (raw.startsWith('"')) { + try { return JSON.parse(raw); } catch (_) {} + } + return raw.replace(/^['"]|['"]$/g, ''); + } + const section = content.match(/## Objective\s+([\s\S]*?)(?:\n## |$)/); + return section ? section[1].trim() : null; + } catch (_) { + return null; + } +} + +async function planIssueIntake({ argvPrefix, srcDir, projectDir, url }) { + const planArgsBase = ['issue-fix', 'workflow-plan', '--url', String(url).trim()]; + if (projectDir) planArgsBase.push('--repo-path', projectDir); + let result, payload; + ({ result, payload } = await runJson(argvPrefix, projectDir, [ + ...planArgsBase, '--fetch-metadata', '--fetch-timeout-seconds', '20', + ], { srcDir, timeoutMs: 90000 })); + let fetchedMetadata = true; + const planEmpty = (value) => !value || value.ok === false + || !(value.ordered_loopx_todo_writeback_preview || []).length; + if (planEmpty(payload)) { + fetchedMetadata = false; + ({ result, payload } = await runJson(argvPrefix, projectDir, planArgsBase, { + srcDir, timeoutMs: 90000, + })); + } + if (!payload) { + return { ok: false, error: result.stderr.trim() || 'loopx returned no JSON payload', raw: null }; + } + const preview = (payload.ordered_loopx_todo_writeback_preview || []) + .filter((entry) => entry && entry.task_class && entry.text) + .map((entry) => ({ + order: entry.planner_order ?? null, + role: entry.role || 'agent', + priority: entry.priority ?? null, + taskClass: entry.task_class, + actionKind: entry.action_kind ?? null, + text: entry.text, + })); + return { + ok: result.code === 0 && payload.ok !== false, + fetchedMetadata, + issueSignal: payload.issue_signal ?? null, + branchPlan: payload.branch_plan ?? null, + feasibilityRoutes: payload.feasibility_checkpoint_plan?.routes ?? null, + todosPreview: preview, + raw: payload, + }; +} + +async function writePlannedTodos({ argvPrefix, srcDir, projectDir, goalId, agentId, intake }) { + const preview = intake.todosPreview || []; + if (!preview.length) return { ...intake, ok: false, error: 'workflow-plan produced no writable todos' }; + // Lowercase to match githubReferences' canonical repo labels — a goal must + // not accumulate two case-variant task_repository identities. + const repoLabel = (intake.issueSignal?.repo || '').toLowerCase() || null; + const written = []; + for (const todo of preview) { + const args = [ + 'todo', 'add', '--goal-id', goalId, '--role', todo.role, + '--task-class', todo.taskClass, '--text', todo.text, + ]; + if (todo.actionKind) args.push('--action-kind', todo.actionKind); + if (repoLabel) args.push('--task-repository', `git:github.com/${repoLabel}`); + if (todo.role === 'agent' && agentId) args.push('--claimed-by', agentId); + try { + const response = await runJson(argvPrefix, projectDir, args, { srcDir }); + const ok = response.result.code === 0 && response.payload?.ok !== false; + // Plan-level user gates (e.g. "approve this fix plan" with its + // write/publish scopes) were implicitly approved when the user + // confirmed the intake sheet — complete them immediately so the task + // auto-runs instead of parking in a gate that repeats the consent + // already given. + if (ok && todo.role === 'user' && response.payload?.todo_id) { + const completeArgs = [ + 'todo', 'complete', '--goal-id', goalId, + '--todo-id', response.payload.todo_id, + '--note', '由任务入库确认自动批准', + ]; + if (todo.taskClass === 'user_gate') completeArgs.push('--decision-outcome', 'approve'); + try { + await runJson(argvPrefix, projectDir, completeArgs, { srcDir, timeoutMs: 60000 }); + } catch (_) {} + } + written.push({ + actionKind: todo.actionKind, + ok, + todoId: response.payload?.todo_id ?? null, + error: ok ? null : (response.payload?.error || response.result.stderr.slice(0, 200) || 'todo add failed'), + }); + } catch (err) { + written.push({ actionKind: todo.actionKind, ok: false, todoId: null, error: String(err.message || err) }); + } + } + return { ...intake, written, ok: intake.ok && written.every((entry) => entry.ok) }; +} + +// ── GitHub issue enumeration (batch-fix intake) ─────────── +// loopx itself has no repo-wide issue listing (issue-fix workflow-plan takes +// exactly one --url), so the console enumerates open issues via the anonymous +// GitHub REST API. Public repos only; unauthenticated quota is 60 req/hour. +// With a token the request authenticates (Bearer) and POST/PUT become +// available — used by the PR publish flow (fork / push / pull request). +function githubApiRequest(apiPath, { + method = 'GET', token = null, jsonBody = null, allow404 = false, +} = {}) { + return new Promise((resolve, reject) => { + const bodyText = jsonBody === null ? null : JSON.stringify(jsonBody); + const headers = { + 'User-Agent': 'bitfun-loopx', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (token) headers.Authorization = `Bearer ${token}`; + if (bodyText) headers['Content-Type'] = 'application/json'; + const req = https.request({ + hostname: 'api.github.com', + path: apiPath, + method, + headers, + timeout: 30000, + }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + if (allow404 && res.statusCode === 404) { resolve(null); return; } + if (res.statusCode === 403 || res.statusCode === 429) { + reject(new Error(res.headers['x-ratelimit-remaining'] === '0' + ? 'GitHub API rate limit exceeded; retry later' + : `GitHub API refused the request (HTTP ${res.statusCode})`)); + return; + } + if (res.statusCode === 401) { + reject(new Error('GitHub token is invalid or expired')); + return; + } + if (res.statusCode === 404) { + reject(new Error(`GitHub repository not found (or private): ${apiPath.split('?')[0]}`)); + return; + } + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`GitHub API HTTP ${res.statusCode} for ${method} ${apiPath.split('?')[0]}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch (err) { + reject(new Error(`GitHub API returned invalid JSON: ${err.message}`)); + } + }); + }); + req.on('timeout', () => { req.destroy(new Error('GitHub API request timed out')); }); + req.on('error', reject); + if (bodyText) req.write(bodyText); + req.end(); + }); +} + +function githubApiGet(apiPath) { + return githubApiRequest(apiPath); +} + +// Re-pasting an already-intaken issue must not mint a duplicate todo: check +// the goal's current todos for an open entry containing this issue URL. +async function hasOpenIssueTodo(argvPrefix, srcDir, projectDir, goalId, url) { + try { + const { result, payload } = await runJson(argvPrefix, projectDir, ['todo', 'list', '--goal-id', goalId], { srcDir, timeoutMs: 60000 }); + if (result.code !== 0) return false; + const todos = (payload && payload.todos) || []; + return todos.some((td) => td.status !== 'done' && String(td.text || td.title || '').includes(url)); + } catch (_) { + return false; + } +} + +// One-shot git invocation bound to a checkout directory. +function gitRun(projectDir, args, timeoutMs = 60000) { + return new Promise((resolve, reject) => { + const child = spawn('git', args, { cwd: projectDir, windowsHide: true }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + const timer = setTimeout(() => { + try { child.kill(); } catch (_) {} + reject(new Error(`git ${args[0]} timed out after ${Math.round(timeoutMs / 1000)}s`)); + }, timeoutMs); + child.on('error', (err) => { clearTimeout(timer); reject(err); }); + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`git ${args[0]} failed (exit ${code}): ${stderr.trim().slice(0, 300)}`)); + }); + }); +} + +// BitFun itself does not store GitHub tokens — its own review platform +// reuses the local GitHub CLI credentials. Do the same here: when the user +// has run `gh auth login` on this machine, publish can authenticate without +// a pasted PAT. +function ghExeName() { + return process.platform === 'win32' ? 'gh.exe' : 'gh'; +} + +function ghExeCandidates() { + const list = []; + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) list.push(path.join(localAppData, 'Programs', 'GitHub CLI', 'gh.exe')); + if (process.platform === 'darwin') { + list.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh'); + } else if (process.platform === 'linux') { + list.push('/usr/local/bin/gh', '/usr/bin/gh'); + } + // User-local install dir used by the direct-download fallback (no root and + // no package manager required); scanned on every platform. + list.push(path.join(consoleHome(), 'bin', ghExeName())); + list.push('gh'); + return list; +} + +// gh is not on PATH right after a fresh winget install (the worker's PATH is +// fixed at spawn time), so resolve the exe explicitly and reuse it everywhere. +async function findGhExe() { + for (const candidate of ghExeCandidates()) { + if (candidate === 'gh') { + try { + const probe = await spawnLoopx({ argv: ['gh'] }, ['--version'], { timeoutMs: 8000 }); + if (probe.code === 0) return 'gh'; + } catch (_) { /* fall through to absolute paths */ } + } else if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function ghCliToken() { + return new Promise((resolve) => { + findGhExe().then((gh) => { + if (!gh) { resolve(null); return; } + const child = spawn(gh, ['auth', 'token'], { windowsHide: true }); + let stdout = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (d) => { stdout += d; }); + const timer = setTimeout(() => { + try { child.kill(); } catch (_) {} + resolve(null); + }, 10000); + child.on('error', () => { clearTimeout(timer); resolve(null); }); + child.on('close', (code) => { + clearTimeout(timer); + const token = stdout.trim(); + resolve(code === 0 && token ? token : null); + }); + }); + }); +} + +// Windows system proxy (WinINET). gh and winget honor it when network is +// otherwise restricted; surfaced in the login progress so the user knows the +// download is going through their configured proxy. +function readWindowsSystemProxy() { + return new Promise((resolve) => { + try { + const child = spawn('reg', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', '/v', 'ProxyServer'], { windowsHide: true }); + let out = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (d) => { out += d; }); + child.on('error', () => resolve(null)); + child.on('close', () => { + const m = out.match(/ProxyServer\s+REG_SZ\s+([^\r\n]+)/i); + resolve(m ? m[1].trim() : null); + }); + } catch (_) { resolve(null); } + }); +} + +function proxyUrlFrom(value) { + if (!value) return null; + // Multi-protocol forms like "http=127.0.0.1:7890;https=127.0.0.1:7890" + const first = String(value).split(';')[0].replace(/^[a-z]+=/, '').trim(); + if (!first) return null; + return /^https?:\/\//i.test(first) ? first : `http://${first}`; +} + +// POSIX mirror of the Windows system-proxy lookup: curl and gh honor the +// standard https_proxy environment variables when present. +function envProxyUrl() { + const value = process.env.HTTPS_PROXY || process.env.https_proxy + || process.env.HTTP_PROXY || process.env.http_proxy; + return proxyUrlFrom(value); +} + +// winget is not guaranteed to exist (App Installer is absent on some +// machines); resolve it by absolute candidates and fall back to the PATH. +function wingetCandidates() { + const list = []; + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + list.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'winget.exe')); + list.push(path.join(localAppData, 'Microsoft', 'WinGet', 'Links', 'winget.exe')); + } + list.push('winget'); + return list; +} + +async function findWinget() { + for (const candidate of wingetCandidates()) { + if (candidate === 'winget') { + try { + const probe = await spawnLoopx({ argv: ['winget'] }, ['--version'], { timeoutMs: 8000 }); + if (probe.code === 0) return 'winget'; + } catch (_) { /* fall through */ } + } else if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function powershellExe() { + const sys = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + return fs.existsSync(sys) ? sys : 'powershell'; +} + +// Platform release-asset shape for the direct gh download (used when no +// package manager is available). gh ships windows_amd64.zip, macOS_<arch>.zip +// and linux_<arch>.tar.gz. +function ghReleaseAsset() { + if (process.platform === 'win32') { + return { pattern: /windows_amd64\.zip$/i, name: 'windows_amd64.zip', archive: 'zip' }; + } + if (process.platform === 'darwin') { + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + return { pattern: new RegExp(`macOS_${arch}\\.zip$`, 'i'), name: `macOS_${arch}.zip`, archive: 'zip' }; + } + if (process.platform === 'linux') { + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + return { pattern: new RegExp(`linux_${arch}\\.tar\\.gz$`, 'i'), name: `linux_${arch}.tar.gz`, archive: 'tar.gz' }; + } + return null; +} + +// Direct-download gh installer (no winget/brew/root required): fetches the +// platform release archive from GitHub and places the single gh binary into a +// user-owned directory that findGhExe already scans. Windows downloads through +// PowerShell's Invoke-WebRequest (honors the system proxy); POSIX uses curl, +// which honors https_proxy. +async function installGhFromRelease(emit) { + const assetInfo = ghReleaseAsset(); + if (!assetInfo) { + throw new Error('当前平台暂不支持自动安装 GitHub CLI,请手动安装:https://github.com/cli/cli/releases'); + } + emit('未找到包管理器,改为直接下载 GitHub CLI 安装包…'); + let url = null; + try { + const release = await githubApiRequest('/repos/cli/cli/releases/latest'); + const asset = release && Array.isArray(release.assets) + ? release.assets.find((a) => a && assetInfo.pattern.test(String(a.browser_download_url || ''))) + : null; + url = asset ? asset.browser_download_url : null; + } catch (_) { /* fall through to the error below */ } + if (!url) { + throw new Error(`无法获取 GitHub CLI 最新版本信息。请手动安装:https://github.com/cli/cli/releases(下载 ${assetInfo.name} 并解压)`); + } + const isWindows = process.platform === 'win32'; + const dstDir = isWindows + ? path.join(process.env.LOCALAPPDATA || os.tmpdir(), 'Programs', 'GitHub CLI') + : path.join(consoleHome(), 'bin'); + const binName = ghExeName(); + const dst = path.join(dstDir, binName); + const stamp = Date.now(); + const tmpArchive = path.join(os.tmpdir(), `gh-install-${stamp}${assetInfo.archive === 'zip' ? '.zip' : '.tar.gz'}`); + const expandDir = path.join(os.tmpdir(), `gh-extract-${stamp}`); + try { + emit(`下载 ${url.slice(0, 120)} …`); + if (isWindows) { + const ps = `[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '${url}' -OutFile '${tmpArchive}' -UseBasicParsing`; + const dl = await spawnLoopx( + { argv: [powershellExe()] }, + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps], + { timeoutMs: 300000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (dl.code !== 0 || !fs.existsSync(tmpArchive)) throw new Error('下载失败'); + } else { + const dl = await spawnLoopx( + { argv: ['curl'] }, + ['-fL', '--retry', '3', '-o', tmpArchive, url], + { timeoutMs: 300000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (dl.code !== 0 || !fs.existsSync(tmpArchive)) { + throw new Error('下载失败(需要 curl,或手动安装:https://github.com/cli/cli/releases)'); + } + } + emit('下载完成,正在解压…'); + fs.mkdirSync(expandDir, { recursive: true }); + if (isWindows) { + const ex = await spawnLoopx( + { argv: [powershellExe()] }, + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `Expand-Archive -Path '${tmpArchive}' -DestinationPath '${expandDir}' -Force`], + { timeoutMs: 120000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (ex.code !== 0) throw new Error('解压失败'); + } else if (assetInfo.archive === 'zip') { + const ex = await spawnLoopx( + { argv: ['unzip'] }, + ['-o', tmpArchive, '-d', expandDir], + { timeoutMs: 120000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (ex.code !== 0) throw new Error('解压失败(需要 unzip)'); + } else { + const ex = await spawnLoopx( + { argv: ['tar'] }, + ['-xzf', tmpArchive, '-C', expandDir], + { timeoutMs: 120000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (ex.code !== 0) throw new Error('解压失败(需要 tar)'); + } + // Release layout: <root>/gh_<version>_<platform>/bin/gh[.exe] + let ghBin = null; + for (const entry of fs.readdirSync(expandDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(expandDir, entry.name, 'bin', binName); + if (fs.existsSync(candidate)) { ghBin = candidate; break; } + } + if (!ghBin) throw new Error('安装包内未找到 gh 二进制'); + fs.mkdirSync(dstDir, { recursive: true }); + fs.copyFileSync(ghBin, dst); + if (!isWindows) fs.chmodSync(dst, 0o755); + emit('GitHub CLI 安装完成'); + } catch (err) { + throw new Error(`直接安装失败:${String(err.message || err)}。可手动安装:https://github.com/cli/cli/releases`); + } finally { + try { fs.rmSync(tmpArchive, { force: true }); } catch (_) {} + try { fs.rmSync(expandDir, { recursive: true, force: true }); } catch (_) {} + } +} + +// Try the platform package manager for gh: winget on Windows, Homebrew on +// macOS. Returns null when gh was installed or no package manager exists; +// returns a user-facing error string when an install was attempted and failed +// (never silently fall back to the direct download after a visible failure). +async function installGhViaPackageManager(emit, envOverlay) { + if (process.platform === 'win32') { + const winget = await findWinget(); + if (!winget) return null; + emit(`通过 winget(${winget})安装…`); + try { + const install = await spawnLoopx( + { argv: [winget], env: envOverlay }, + ['install', '--id', 'GitHub.cli', '-e', '--accept-source-agreements', '--accept-package-agreements'], + { timeoutMs: 600000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (install.code !== 0) { + throw new Error(String(install.stderr || install.stdout || '').trim().slice(-200) || `winget exit ${install.code}`); + } + } catch (err) { + return `安装 GitHub CLI 失败:${String(err.message || err)}。可手动安装后再试:https://github.com/cli/cli/releases`; + } + return null; + } + if (process.platform === 'darwin') { + const brew = await findBrew(); + if (!brew) return null; + emit(`通过 Homebrew(${brew})安装…`); + try { + const install = await spawnLoopx( + { argv: [brew], env: envOverlay }, + ['install', 'gh'], + { timeoutMs: 600000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (install.code !== 0) { + throw new Error(String(install.stderr || install.stdout || '').trim().slice(-200) || `brew exit ${install.code}`); + } + } catch (err) { + return `安装 GitHub CLI 失败:${String(err.message || err)}。可手动安装后再试:https://github.com/cli/cli/releases`; + } + return null; + } + // Linux has no universal package manager; the direct release download + // covers it without root privileges. + return null; +} + +async function findBrew() { + for (const candidate of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) { + if (!fs.existsSync(candidate)) continue; + try { + const probe = await spawnLoopx({ argv: [candidate] }, ['--version'], { timeoutMs: 8000 }); + if (probe.code === 0) return candidate; + } catch (_) { /* try the next candidate */ } + } + return null; +} + +// gh's web login flow needs an interactive terminal to display the one-time +// code: Windows opens a fresh console via `cmd start`, macOS opens a Terminal +// window via osascript, Linux tries the common terminal emulators in order. +// Resolves null once a launcher has been started (best-effort) or a +// user-facing guidance string when no interactive terminal could be opened. +function launchGhAuthLogin(gh, envOverlay) { + const authArgs = ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web']; + const spawnDetached = (exe, args, opts = {}) => new Promise((resolve) => { + let done = false; + const finish = (value) => { if (!done) { done = true; resolve(value); } }; + try { + const child = spawn(exe, args, { + detached: true, + stdio: 'ignore', + env: envOverlay ? { ...process.env, ...envOverlay } : process.env, + ...opts, + }); + child.on('spawn', () => finish(null)); + child.on('error', (err) => finish(String(err && err.message || err))); + // No early error within the window: assume the launcher took over. + setTimeout(() => finish(null), 1500); + } catch (err) { + finish(String(err && err.message || err)); + } + }); + const guidance = () => '无法自动打开登录终端。请在自己的终端执行 `gh auth login` 后回到控制台重试。'; + if (process.platform === 'win32') { + return spawnDetached('cmd', ['/c', 'start', '', gh, ...authArgs], { windowsHide: false }).then( + (err) => (err ? `无法启动登录窗口:${err}。${guidance()}` : null), + ); + } + if (process.platform === 'darwin') { + // JSON.stringify yields a double-quoted, escaped token; joining them and + // wrapping the result in another JSON.stringify produces a valid + // AppleScript string literal for `do script`. + const cmdLine = [gh, ...authArgs].map((s) => JSON.stringify(String(s))).join(' '); + const script = `tell application "Terminal" to do script ${JSON.stringify(cmdLine)}`; + return spawnDetached('osascript', ['-e', script]).then( + (err) => (err ? `无法打开终端窗口:${err}。${guidance()}` : null), + ); + } + // Linux terminal emulators take the program and its args directly; no shell + // quoting is involved. + const terminals = [ + ['x-terminal-emulator', ['-e', gh, ...authArgs]], + ['gnome-terminal', ['--', gh, ...authArgs]], + ['konsole', ['-e', gh, ...authArgs]], + ['xterm', ['-e', gh, ...authArgs]], + ]; + return (async () => { + for (const [exe, args] of terminals) { + const err = await spawnDetached(exe, args); + if (err === null) return null; + } + return guidance(); + })(); +} + +// Issue bodies frequently carry the real problem in screenshots. Detect image +// references (markdown images, <img> tags, GitHub attachment URLs) so the UI +// can be conservative with text-only models. +function bodyHasImages(body) { + const s = String(body || ''); + if (!s) return false; + if (/!\[[^\]]*\]\([^)]*\)/i.test(s)) return true; + if (/<img\b/i.test(s)) return true; + if (/user[-_](images|attachments)\.githubusercontent\.com|github\.com\/user-attachments\/assets/i.test(s)) return true; + return false; +} + +async function fetchOpenIssues(repo) { + const issues = []; + // /repos/…/issues interleaves PRs (every PR is an issue) — filter them out, + // otherwise a busy repo's fix loop would keep "fixing" its own PRs. + for (let page = 1; page <= 3; page += 1) { + const batch = await githubApiGet(`/repos/${repo}/issues?state=open&per_page=100&page=${page}`); + if (!Array.isArray(batch)) break; + for (const item of batch) { + if (!item || item.pull_request || !item.number) continue; + issues.push({ + number: item.number, + title: item.title || `#${item.number}`, + url: `https://github.com/${repo}/issues/${item.number}`, + labels: (item.labels || []).map((l) => (typeof l === 'string' ? l : l && l.name)).filter(Boolean), + comments: item.comments ?? 0, + updatedAt: item.updated_at || null, + hasImages: bodyHasImages(item.body), + }); + } + if (batch.length < 100) return { issues, truncated: false }; + } + // Page 3 came back full — more issues/PRs likely exist beyond the cap. + return { issues, truncated: true }; +} + +module.exports = { + async 'loopx.detect'({ argvPrefix = null, srcDir = null } = {}) { + dbgWorker('detect:start', `argvPrefix=${JSON.stringify(argvPrefix)} srcDir=${srcDir || ''}`); + return detectLoopx(argvPrefix, srcDir); + }, + + // One-click bootstrap: pip-install loopx from source. Progress lines stream + // through installLoopx:progress events. Absolute python.exe candidates come + // first (the worker PATH may be restricted); pip-on-PATH is the fallback. + async 'loopx.installLoopx'({} = {}) { + const emit = (line) => global.rpcEmit('installLoopx:progress', { line }); + const target = 'git+https://github.com/huangruiteng/loopx.git'; + const attempts = []; + for (const pythonExe of pythonCandidates()) { + attempts.push({ argv: [pythonExe, '-m', 'pip', 'install', target] }); + } + attempts.push({ argv: ['pip', 'install', target] }); + attempts.push({ argv: ['python', '-m', 'pip', 'install', target] }); + if (process.platform !== 'win32') { + attempts.push({ argv: ['python3', '-m', 'pip', 'install', target] }); + } + let lastError = null; + for (const prefix of attempts) { + emit(`$ ${prefix.argv.join(' ')}`); + try { + const { code, stderr } = await spawnLoopx(prefix, [], { + timeoutMs: 600000, + onStderrLine: (line) => emit(String(line).slice(0, 140)), + }); + if (code === 0) { + emit('install complete'); + cachedPrefix = null; + const detected = await detectLoopx(null, null); + return { ok: true, found: detected.found, version: detected.version || null }; + } + lastError = stderr.slice(-300) || `exit ${code}`; + } catch (err) { + lastError = String(err.message || err); + } + emit(`failed: ${lastError.slice(0, 140)}`); + } + return { ok: false, error: lastError || 'pip install failed' }; + }, + + // Prerequisite probe for the universal acquisition path. loopx itself has no + // runtime dependencies, but running it from source needs a Python >= 3.11 + // interpreter and fetching the source needs git. Reported per-item so the UI + // can name exactly what is missing (never a silent failure). + async 'loopx.checkPrereqs'({} = {}) { + const python = await probePython(); + const git = await probeGit(); + return { ready: !!(python.ok && git.found), python, git }; + }, + + // One-click vendor: clone (or re-pin) the loopx source checkout into + // ~/.bitfun/bitfun-loopx/vendor/loopx at the known-good version + // LOOPX_VENDOR_REF and run it straight from there via + // `python -m loopx.cli` + PYTHONPATH. No pip, no global install. Progress + // streams through vendorLoopx:progress events. + async 'loopx.ensureVendor'({} = {}) { + const emit = (line) => global.rpcEmit('vendorLoopx:progress', { line }); + const prereqs = await checkPrereqs(); + if (!prereqs.ready) { + return { ok: false, stage: 'prereqs', prereqs, error: prereqErrorMessage(prereqs) }; + } + const dir = vendorLoopxDir(); + const gitEnv = { GIT_TERMINAL_PROMPT: '0' }; + try { + if (!fs.existsSync(path.join(dir, 'loopx', 'cli.py'))) { + emit(`$ git clone --depth 1 --branch ${LOOPX_VENDOR_REF} ${LOOPX_REPO_URL} "${dir}"`); + const clone = await spawnLoopx( + { argv: ['git'], env: gitEnv }, + ['clone', '--depth', '1', '--branch', LOOPX_VENDOR_REF, LOOPX_REPO_URL, dir], + { timeoutMs: 300000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + if (clone.code !== 0) { + throw new Error(clone.stderr.trim().slice(-200) || `git clone failed (exit ${clone.code})`); + } + } else { + // Existing checkout: re-pin to LOOPX_VENDOR_REF best-effort (offline + // keeps the current copy; the pin heals on a later successful run). + emit(`$ git -C "${dir}" fetch --depth 1 origin tag ${LOOPX_VENDOR_REF}`); + try { + await spawnLoopx( + { argv: ['git'], env: gitEnv }, + ['-C', dir, 'fetch', '--depth', '1', 'origin', 'tag', LOOPX_VENDOR_REF], + { timeoutMs: 120000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + await spawnLoopx( + { argv: ['git'], env: gitEnv }, + ['-C', dir, '-c', 'advice.detachedHead=false', 'checkout', '--force', LOOPX_VENDOR_REF], + { timeoutMs: 60000, onStderrLine: (line) => emit(String(line).slice(0, 140)) }, + ); + } catch (_) { /* stale checkout stays usable */ } + } + const detected = await detectLoopx(null, dir); + if (detected.found) { + cachedPrefix = detected.argvPrefix; + return { + ok: true, + found: true, + version: detected.version, + srcDir: dir, + argvPrefix: detected.argvPrefix, + prereqs, + }; + } + const detail = (detected.probes || []) + .map((p) => `${(p.argvPrefix || []).join(' ')} → ${p.ok ? p.version : p.error || 'failed'}`) + .join('\n'); + return { + ok: false, + stage: 'detect', + prereqs, + error: `loopx source fetched but not runnable (Python >= 3.11 required):\n${detail}`, + }; + } catch (err) { + return { ok: false, stage: 'clone', prereqs, error: String(err.message || err) }; + } + }, + + async 'loopx.doctor'({ argvPrefix = null, projectDir = null } = {}) { + const { result, payload } = await runJson(argvPrefix, projectDir, ['doctor']); + return { ok: result.code === 0, payload, stderr: result.stderr }; + }, + + // v3.2: adopt a goal created by another loopx host on this machine so the + // board starts monitoring it. Registers the agent on the goal (idempotent); + // execution turns still need a known project directory, which the UI binds + // separately. + async 'loopx.adoptGoal'({ + argvPrefix = null, srcDir = null, projectDir = null, goalId, agentId, + } = {}) { + if (!goalId) throw new Error('loopx.adoptGoal: goalId is required'); + if (!agentId) { + return { ok: true, goalId, registered: false, error: null }; + } + dbgWorker('adoptGoal:start', `goalId=${goalId} agentId=${agentId}`); + const { result, payload } = await runJson(argvPrefix, projectDir, [ + 'register-agent', '--goal-id', goalId, '--agent-id', agentId, '--execute', + ], { srcDir, timeoutMs: 60000 }); + const ok = result.code === 0 && payload?.ok !== false; + dbgWorker('adoptGoal:done', `ok=${ok}`); + return { + ok, + goalId, + registered: ok, + error: ok ? null : (payload?.error || result.stderr.slice(0, 300) || 'agent registration failed'), + }; + }, + + // Mid-task human intervention: a free-text message from the user is written + // as a user-lane todo bound to the goal's agent lane (task-class + // user_action — NOT user_gate: this is a message, not a blocking decision). + // loopx delivers it as the "post-response continuation" for that lane, so + // the agent reads it on its next turn. + async 'loopx.guideGoal'({ + argvPrefix = null, srcDir = null, projectDir = null, + goalId, agentId = null, text, + } = {}) { + if (!goalId) throw new Error('loopx.guideGoal: goalId is required'); + const message = String(text || '').trim(); + if (!message) throw new Error('loopx.guideGoal: text is required'); + if (message.length > 2000) throw new Error('loopx.guideGoal: text is too long (max 2000 characters)'); + dbgWorker('guideGoal:start', `goalId=${goalId} agentId=${agentId || ''} text=${message.slice(0, 80)}`); + const args = [ + 'todo', 'add', '--goal-id', goalId, + '--role', 'user', '--task-class', 'user_action', '--text', message, + ]; + if (agentId) args.push('--bound-agent', agentId); + const { result, payload } = await runJson(argvPrefix, projectDir, args, { srcDir, timeoutMs: 60000 }); + const ok = result.code === 0 && payload?.ok !== false; + dbgWorker('guideGoal:done', `ok=${ok}`); + return { + ok, + goalId, + todoId: payload?.todo_id ?? null, + error: ok ? null : (payload?.error || result.stderr.slice(0, 300) || 'todo add failed'), + }; + }, + + // "Paste an issue URL" glue: preview via `issue-fix workflow-plan + // --fetch-metadata`, then (execute=true) materialize the ordered todo + // preview into a goal with plain `loopx todo add` calls. Uses only + // shipped loopx CLI surface — no loopx-side changes. + async 'loopx.issueIntake'({ + argvPrefix = null, + srcDir = null, + projectDir = null, + url, + goalId = null, + execute = false, + } = {}) { + if (!url || !/^https:\/\/github\.com\//.test(String(url).trim())) { + throw new Error('loopx.issueIntake: url must be a public https://github.com/ issue or PR link'); + } + const intake = await planIssueIntake({ argvPrefix, srcDir, projectDir, url }); + if (!execute) return intake; + if (!goalId) throw new Error('loopx.issueIntake: goalId is required to write todos'); + return writePlannedTodos({ argvPrefix, srcDir, projectDir, goalId, agentId: null, intake }); + }, + + // Probe the local GitHub CLI credential (`gh auth token`). The UI uses it + // before asking the user to paste a PAT: a machine that already ran + // `gh auth login` needs no manual token. + async 'loopx.githubGhToken'() { + const token = await ghCliToken(); + return { ok: Boolean(token), token: token || null }; + }, + + // One-click full reset: back up (rename) and wipe every loopx data + // location this console touches — the global registry/runtime, per-checkout + // registries and goal states, and the console's clone cache. Nothing is + // unlinked: everything moves into a timestamped backup directory so the + // operation stays recoverable. + async 'loopx.resetAll'({ projectDirs = null } = {}) { + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + const backupRoot = path.join(consoleHome(), `cleared-${stamp}`); + fs.mkdirSync(backupRoot, { recursive: true }); + const moved = []; + const renameAway = (src, name) => { + if (!fs.existsSync(src)) return; + const dst = path.join(backupRoot, name); + try { + fs.renameSync(src, dst); + moved.push(dst); + } catch (err) { + throw new Error(`cannot move ${src} into the backup: ${err.message}`); + } + }; + dbgWorker('resetAll:start', `backup=${backupRoot}`); + renameAway(path.join(os.homedir(), '.codex', 'loopx'), 'loopx-global'); + renameAway(cloneCacheRoot(), 'repos-clone-cache'); + for (const dir of (Array.isArray(projectDirs) ? projectDirs : [])) { + if (!dir) continue; + renameAway(path.join(dir, '.loopx'), `checkout-${Buffer.from(dir).toString('base64').slice(0, 24)}-loopx`); + renameAway(path.join(dir, '.codex', 'goals'), `checkout-${Buffer.from(dir).toString('base64').slice(0, 24)}-goals`); + } + dbgWorker('resetAll:done', `moved=${moved.length}`); + return { ok: true, backupDir: backupRoot, moved }; + }, + + // Product-level task intake. This adapts natural-language goals, repository + // URLs, and one or more Issue/PR URLs onto LoopX's public CLI without + // changing LoopX itself. + // + // Fast read-only classification for the composer: parses the input, expands + // an owner/repo/issues list URL into concrete open issues (GitHub REST, + // anonymous), and runs the repo-binding checks — so the UI can show a + // precise confirmation sheet before anything is written. + async 'loopx.resolveIntake'({ projectDir = null, projectDirs = null, objective } = {}) { + const text = String(objective || '').trim(); + if (!text) throw new Error('loopx.resolveIntake: objective is required'); + dbgWorker('resolveIntake:start', `text=${text.slice(0, 120)}`); + const { refs, unsupported } = githubReferences(text); + dbgWorker('resolveIntake:refs', `refs=${JSON.stringify(refs.map((r) => r.kind + ':' + r.url))} unsupported=${unsupported.length}`); + if (unsupported.length) { + return { + ok: false, + code: 'unsupported_github_path', + url: unsupported[0].url, + error: `Unsupported GitHub link: ${unsupported[0].url}. Paste an issue, a pull request, the repository home, or its issues list.`, + }; + } + if (!refs.length) { + return { + ok: false, + code: 'unsupported_input', + error: 'Paste a GitHub issue, pull request, repository, or issues-list link.', + }; + } + const issueRefs = refs.filter((ref) => ref.kind === 'issue' || ref.kind === 'pr'); + const listRefs = refs.filter((ref) => ref.kind === 'issues-list'); + const requestedRepos = [...new Set(refs.map((ref) => ref.repo))]; + const projectRepo = projectGithubRepository(projectDir); + if (requestedRepos.length > 1) { + return { ok: false, code: 'multiple_repositories', requestedRepos, projectRepo }; + } + if (requestedRepos.length === 1 && projectRepo && requestedRepos[0] !== projectRepo) { + return { + ok: false, + code: 'repository_mismatch', + requestedRepo: requestedRepos[0], + projectRepo, + }; + } + // A GitHub-targeted task against a project dir we cannot identify (no + // .git/config or non-GitHub remote) would silently run fixes in the wrong + // tree. Surface it as its own blocking code. + if (requestedRepos.length === 1 && !projectRepo && projectDir) { + return { + ok: false, + code: 'repository_unverified', + requestedRepo: requestedRepos[0], + projectRepo: null, + }; + } + // No global checkout, or the caller deliberately bypassed it: before + // offering a fresh clone, look for an existing checkout of this exact + // repository among the console's recorded project directories AND the + // stable clone cache — a fresh MiniApp import (empty config) then reuses + // the cached checkout immediately, without even a GitHub lookup. + let reuseDir = null; + if (requestedRepos.length === 1 && !projectDir) { + const searchDirs = Array.isArray(projectDirs) + ? projectDirs.filter((dir) => typeof dir === 'string' && dir) + : []; + try { + const root = cloneCacheRoot(); + if (fs.existsSync(root)) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.isDirectory()) searchDirs.push(path.join(root, entry.name)); + } + } + } catch (_) {} + for (const dir of searchDirs) { + if (projectGithubRepository(dir) === requestedRepos[0]) { + reuseDir = dir; + dbgWorker('resolveIntake:reuse', `${requestedRepos[0]} -> ${dir}`); + break; + } + } + } + // No local checkout at all: direction C — offer auto-clone into the + // MiniApp's own data directory. Verify the repository exists first so a + // typo fails before the user confirms. + let autoClone = false; + if (requestedRepos.length === 1 && !projectDir && !reuseDir) { + let exists = true; + try { + dbgWorker('resolveIntake:repoExists:start', requestedRepos[0]); + exists = await repoExistsOnGithub(requestedRepos[0]); + dbgWorker('resolveIntake:repoExists:done', `${requestedRepos[0]} exists=${exists}`); + } catch (err) { + dbgWorker('resolveIntake:repoExists:error', `${err.message}`); + return { + ok: false, + code: 'repository_lookup_failed', + requestedRepo: requestedRepos[0], + error: `Could not verify ${requestedRepos[0]} on GitHub: ${err.message}`, + }; + } + if (!exists) { + return { + ok: false, + code: 'repository_not_found', + requestedRepo: requestedRepos[0], + error: `GitHub repository not found: ${requestedRepos[0]}`, + }; + } + autoClone = true; + } + let issues = issueRefs.filter((ref) => ref.kind === 'issue').map((ref) => ({ + number: ref.number, title: `#${ref.number}`, url: ref.url, fromList: false, + hasImages: false, + })); + // Single-issue intake: fetch the body so the sheet can warn about images + // that a text-only model cannot see (bounded to a handful of links to + // stay inside the anonymous rate limit). + if (issues.length > 0 && issues.length <= 5) { + await Promise.all(issues.map(async (issue) => { + try { + const detail = await githubApiGet(`/repos/${refs[0].repo}/issues/${issue.number}`); + if (detail && typeof detail.body === 'string') issue.hasImages = bodyHasImages(detail.body); + } catch (_) { /* conservative: keep false */ } + })); + } + let kind = issueRefs.length ? (issueRefs.length > 1 ? 'issues' : 'issue') : 'repository'; + let truncated = false; + // Both an explicit issues-list URL and a bare repository URL mean "the + // repo's open issues" — one link, one behavior, the sheet re-selects. + const repoRefs = refs.filter((ref) => ref.kind === 'repository'); + const expandRepo = listRefs.length ? listRefs[0].repo + : (repoRefs.length && !issueRefs.length ? repoRefs[0].repo : null); + if (expandRepo) { + kind = 'issues-list'; + dbgWorker('resolveIntake:fetchIssues:start', expandRepo); + const fetched = await fetchOpenIssues(expandRepo); + dbgWorker('resolveIntake:fetchIssues:done', `count=${fetched.issues.length} truncated=${fetched.truncated}`); + truncated = fetched.truncated; + const seen = new Set(issues.map((issue) => issue.url)); + for (const issue of fetched.issues) { + if (!seen.has(issue.url)) issues.push({ ...issue, fromList: true }); + } + } + return { + ok: true, + kind, + repo: (refs[0] && refs[0].repo) || projectRepo || null, + projectRepo, + reuseDir, + issues, + truncated, + prCount: issueRefs.filter((ref) => ref.kind === 'pr').length, + autoClone, + }; + }, + + // List a goal's todos (zero-write projection). Used to surface open + // user-lane gates on the board. + // Delete a task entirely: archive its runtime directory via loopx + // (--allow-registered --execute) and remove the registry entry, keeping a + // backup of the registry file before rewriting it. + async 'loopx.deleteGoal'({ argvPrefix = null, srcDir = null, projectDir = null, goalId } = {}) { + if (!goalId) throw new Error('loopx.deleteGoal: goalId is required'); + dbgWorker('deleteGoal:start', `goalId=${goalId} projectDir=${projectDir || '(global)'}`); + // Archive tolerantly: the runtime may already be archived by an earlier + // attempt (or loopx's own archival) — that still counts as archived for + // our purposes, so the registry surgery below always gets its chance. + let archived = false; + let archiveError = null; + for (const dir of projectDir ? [projectDir, null] : [null]) { + try { + const archive = await runJson(argvPrefix, dir, [ + 'archive-runtime', '--goal-id', goalId, '--allow-registered', '--execute', + ], { srcDir, timeoutMs: 120000 }); + if (archive.result.code === 0 && archive.payload?.ok !== false) { + archived = true; + archiveError = null; + break; + } + archiveError = archive.payload?.error || archive.result.stderr.trim() || 'archive-runtime failed'; + // A missing source dir means it is already archived — good enough. + if (/does not exist|not found|already archived/i.test(archiveError)) { + archived = true; + archiveError = null; + break; + } + } catch (err) { + archiveError = String(err.message || err); + } + } + if (!archived) { + return { + ok: false, goalId, archived: false, registryRemoved: false, + error: archiveError || 'archive-runtime failed', + }; + } + // Registry surgery: drop the goal entry from the project AND the global + // registry (loopx keeps a global route for every goal), with timestamped + // backups of both files. + const registryPaths = [...new Set([ + projectDir ? path.join(projectDir, '.loopx', 'registry.json') : resolveRegistryPath(null), + resolveRegistryPath(null), + ])]; + let registryRemoved = false; + for (const registryPath of registryPaths) { + try { + const raw = fs.readFileSync(registryPath, 'utf8'); + const registry = JSON.parse(raw); + if (Array.isArray(registry.goals)) { + const before = registry.goals.length; + registry.goals = registry.goals.filter((goal) => (goal.goal_id || goal.id) !== goalId); + if (registry.goals.length < before) { + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + fs.copyFileSync(registryPath, `${registryPath}.del-bak-${stamp}`); + registry.updated_at = new Date().toISOString(); + fs.writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`); + registryRemoved = true; + } + } + } catch (err) { + dbgWorker('deleteGoal:registryError', `${err.message}`); + } + } + // Belt and braces: a leftover runtime directory can be re-registered by a + // later loopx sync and resurrect the goal — move it aside under a backup + // name instead of leaving it in place. + try { + const goalsRoot = path.join(os.homedir(), '.codex', 'loopx', 'goals'); + const runtimeDir = path.join(goalsRoot, goalId); + if (fs.existsSync(runtimeDir)) { + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + fs.renameSync(runtimeDir, `${runtimeDir}.del-bak-${stamp}`); + dbgWorker('deleteGoal:runtimeMoved', `${runtimeDir}.del-bak-${stamp}`); + } + } catch (err) { + dbgWorker('deleteGoal:runtimeMoveError', `${err.message}`); + } + // Archived runtimes live under archived-goals/<goalId>-<stamp> and are + // re-listed by listGoals — deleting an ALREADY-archived goal must move + // those aside too, or the goal resurrects on the next refresh and the UI + // dropdown never updates. Rename (never delete) per the backup discipline. + try { + const archiveRoot = path.join(os.homedir(), '.codex', 'loopx', 'archived-goals'); + if (fs.existsSync(archiveRoot)) { + for (const entry of fs.readdirSync(archiveRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const m = entry.name.match(/^(bfx-.*)-(\d{8}T\d{6}Z)$/); + if (!m || m[1] !== goalId) continue; + const src = path.join(archiveRoot, entry.name); + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + fs.renameSync(src, path.join(archiveRoot, `${entry.name}.del-bak-${stamp}`)); + dbgWorker('deleteGoal:archiveMoved', src); + } + } + } catch (err) { + dbgWorker('deleteGoal:archiveMoveError', `${err.message}`); + } + dbgWorker('deleteGoal:done', `archived=${archived} registryRemoved=${registryRemoved}`); + return { + ok: true, + goalId, + archived, + registryRemoved, + archivePath: null, + warning: registryRemoved ? null : 'runtime archived, but the registry entry could not be removed', + }; + }, + + // Restore an archived console goal: move the newest archived runtime back + // under <runtime-root>/goals and re-add the registry entry (reconstructed + // from the newest registry .del-bak backup that still carries it, with a + // minimal fallback). The task comes back paused; 继续 resumes as usual. + async 'loopx.restoreGoal'({ projectDir = null, goalId, archiveDir = null } = {}) { + if (!goalId) throw new Error('loopx.restoreGoal: goalId is required'); + dbgWorker('restoreGoal:start', `goalId=${goalId}`); + const loopxRoot = path.join(os.homedir(), '.codex', 'loopx'); + // 1. Locate the newest archive dir for this goal. + const archiveRoot = path.join(loopxRoot, 'archived-goals'); + let source = archiveDir && fs.existsSync(archiveDir) ? archiveDir : null; + if (!source && fs.existsSync(archiveRoot)) { + let best = null; + let bestStamp = ''; + for (const entry of fs.readdirSync(archiveRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const m = entry.name.match(/^(bfx-.*)-(\d{8}T\d{6}Z)$/); + if (!m || m[1] !== goalId) continue; + if (m[2] > bestStamp) { bestStamp = m[2]; best = path.join(archiveRoot, entry.name); } + } + source = best; + } + if (!source) return { ok: false, goalId, error: 'archived goal directory not found' }; + // 2. Move the runtime back into the goals root. + const goalsRoot = path.join(loopxRoot, 'goals'); + fs.mkdirSync(goalsRoot, { recursive: true }); + const target = path.join(goalsRoot, goalId); + if (fs.existsSync(target)) return { ok: false, goalId, error: 'goal runtime dir already exists' }; + fs.renameSync(source, target); + // 3. Rebuild the registry entry from the newest del-bak that carries it. + let entry = null; + const bakFiles = fs.existsSync(loopxRoot) + ? fs.readdirSync(loopxRoot).filter((f) => f.startsWith('registry.global.json.del-bak-')).sort().reverse() + : []; + for (const f of bakFiles) { + try { + const reg = JSON.parse(fs.readFileSync(path.join(loopxRoot, f), 'utf8')); + const hit = (reg.goals || []).find((g) => (g.goal_id || g.id) === goalId); + if (hit) { entry = hit; break; } + } catch (_) {} + } + if (!entry) { + entry = { + id: goalId, domain: 'project-goal-control-plane', status: 'active', + role: 'controller', parent_goal_id: null, + coordination: { registered_agents: ['bitfun-agent'] }, + }; + } + // 4. Re-add the entry to the global registry and the repo registry. + const repoDir = (entry && entry.repo) || projectDir || null; + const targets = [path.join(loopxRoot, 'registry.global.json')]; + if (repoDir) targets.push(path.join(repoDir, '.loopx', 'registry.json')); + let restored = false; + for (const rp of targets) { + try { + const raw = fs.readFileSync(rp, 'utf8'); + const reg = JSON.parse(raw); + if (!Array.isArray(reg.goals)) reg.goals = []; + if (!reg.goals.some((g) => (g.goal_id || g.id) === goalId)) { + reg.goals.push(entry); + reg.updated_at = new Date().toISOString(); + fs.writeFileSync(rp, `${JSON.stringify(reg, null, 2)}\n`); + restored = true; + } + } catch (err) { + dbgWorker('restoreGoal:registryError', `${err.message}`); + } + } + dbgWorker('restoreGoal:done', `restored=${restored}`); + return { ok: true, goalId, restored, runtimeDir: target, repoDir: repoDir || null }; + }, + + async 'loopx.listTodos'({ argvPrefix = null, projectDir = null, goalId, role = null, status = null } = {}) { + if (!goalId) throw new Error('loopx.listTodos: goalId is required'); + const { result, payload } = await runJson(argvPrefix, projectDir, ['todo', 'list', '--goal-id', goalId]); + let todos = (payload && payload.todos) || []; + if (role) todos = todos.filter((todo) => todo.role === role); + if (status) todos = todos.filter((todo) => todo.status === status); + return { ok: result.code === 0, todos, error: result.code === 0 ? null : result.stderr.slice(0, 300) }; + }, + + // Per-goal issue tracker: batch intake writes one agent todo per issue + // ("[P1] Fix GitHub issue #N: <title> (<url>)", action_kind=fix_issue), so + // the goal's issue list + per-issue status is a projection over those todos. + // Returns the structured board the card strip renders: url/number/title and + // the todo status (open / blocked / deferred / done). + async 'loopx.goalIssues'({ argvPrefix = null, projectDir = null, goalId } = {}) { + if (!goalId) throw new Error('loopx.goalIssues: goalId is required'); + const { result, payload } = await runJson(argvPrefix, projectDir, ['todo', 'list', '--goal-id', goalId]); + const todos = (payload && payload.todos) || []; + const issues = []; + for (const td of todos) { + if (td.role && td.role !== 'agent') continue; + const text = String(td.text || td.title || ''); + const labeled = text.match(/fix github issue #(\d+)\s*[::]?\s*(.*?)\s*\((https?:\/\/github\.com\/[^\s)]+)\)/i); + if (!labeled && td.action_kind !== 'fix_issue') continue; + let url = null; + let number = null; + let title = ''; + if (labeled) { + number = Number(labeled[1]); + title = labeled[2] || ''; + url = labeled[3]; + } else { + const bare = text.match(/https?:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/(?:issues|pull)\/(\d+)/i); + if (!bare) continue; + url = bare[0]; + number = Number(bare[1]); + } + const status = td.status || 'open'; + issues.push({ + url, + number, + title, + status, + done: status === 'done', + todoId: td.todo_id ?? null, + }); + } + const done = issues.filter((issue) => issue.done).length; + return { + ok: result.code === 0, + issues, + total: issues.length, + done, + open: issues.length - done, + error: result.code === 0 ? null : result.stderr.slice(0, 300), + }; + }, + + // Commit subjects for a branch — feeds the PR "解决方案" section and the + // publish-time cause/solution analysis prompt. + async 'loopx.gitLog'({ projectDir = null, branch = null, limit = 15 } = {}) { + if (!projectDir) throw new Error('loopx.gitLog: projectDir is required'); + const args = ['log', `-${Math.min(Math.max(Number(limit) || 15, 1), 30)}`, '--no-merges', '--pretty=format:%s']; + if (branch) args.push(branch); + const log = await gitRun(projectDir, args, 30000); + return { + ok: true, + subjects: String(log.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean), + }; + }, + + // Changed files of a branch (merge-base..branch) — feeds the PR "涉及文件" + // section and makes the generated solution concrete about WHAT was touched. + async 'loopx.gitDiff'({ projectDir = null, branch = null } = {}) { + if (!projectDir) throw new Error('loopx.gitDiff: projectDir is required'); + const target = branch || 'HEAD'; + let range = null; + for (const ref of ['master', 'main', 'origin/master', 'origin/main']) { + try { + const mb = await gitRun(projectDir, ['merge-base', ref, target], 20000); + const sha = String(mb.stdout || '').trim(); + if (sha) { range = `${sha}..${target}`; break; } + } catch (_) { /* try the next base ref */ } + } + const effective = range || target; + const names = await gitRun(projectDir, ['diff', '--name-only', effective], 30000); + const stat = await gitRun(projectDir, ['diff', '--shortstat', effective], 30000); + return { + ok: true, + range, + files: String(names.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean), + stat: String(stat.stdout || '').trim() || null, + }; + }, + + // Complete (approve) one todo by id — the console's gate-approval action. + // user_gate todos hard-require --decision-outcome (approve/reject/cancel); + // the CLI rejects the flag on other task classes, so pass it conditionally. + async 'loopx.completeTodo'({ + argvPrefix = null, srcDir = null, projectDir = null, + goalId, todoId, note = null, decisionOutcome = null, + } = {}) { + if (!goalId || !todoId) throw new Error('loopx.completeTodo: goalId and todoId are required'); + const args = ['todo', 'complete', '--goal-id', goalId, '--todo-id', todoId]; + if (decisionOutcome) args.push('--decision-outcome', decisionOutcome); + if (note) args.push('--note', note); + const { result, payload } = await runJson(argvPrefix, projectDir, args, { srcDir }); + const ok = result.code === 0 && payload?.ok !== false; + return { ok, payload, error: ok ? null : (payload?.error || result.stderr.slice(0, 300) || 'todo complete failed') }; + }, + + // Validates a GitHub token and returns its login — the settings dialog + // checks the token at configuration time instead of at publish time. + async 'loopx.githubUser'({ token = null } = {}) { + if (!token) throw new Error('loopx.githubUser: token is required'); + const user = await githubApiRequest('/user', { token }); + return { ok: true, login: user && user.login ? user.login : null }; + }, + + // One-click GitHub CLI login: install gh when missing (winget on Windows, + // Homebrew on macOS, direct release download everywhere else), then launch + // `gh auth login --web` in its own terminal window — gh prints a one-time + // code there and opens the browser. The worker polls `gh auth status` until + // the browser flow completes. Progress streams through ghLogin:progress + // events. + async 'loopx.ghLogin'({} = {}) { + const emit = (line) => global.rpcEmit('ghLogin:progress', { line }); + const proxyUrl = process.platform === 'win32' + ? proxyUrlFrom(await readWindowsSystemProxy()) + : envProxyUrl(); + const envOverlay = proxyUrl ? { HTTPS_PROXY: proxyUrl, HTTP_PROXY: proxyUrl } : undefined; + let gh = await findGhExe(); + if (!gh) { + emit('GitHub CLI 未安装,正在安装…'); + if (proxyUrl) emit(`检测到代理:${proxyUrl}(安装将经此代理下载)`); + const pkgError = await installGhViaPackageManager(emit, envOverlay); + gh = await findGhExe(); + if (!gh && pkgError) return { ok: false, error: pkgError }; + if (!gh) { + // No package manager available (or a platform without one): download + // the release archive directly into a user-owned directory. + try { + await installGhFromRelease(emit); + } catch (err) { + return { ok: false, error: String(err.message || err) }; + } + gh = await findGhExe(); + } + if (!gh) return { ok: false, error: '安装完成但未找到 gh,请重开控制台后重试' }; + } + emit(`已找到 ${gh}。启动浏览器登录(弹出窗口会显示一次性代码,浏览器确认后自动完成)…`); + const launchError = await launchGhAuthLogin(gh, envOverlay); + if (launchError) return { ok: false, error: launchError }; + // The login poll can take minutes: emit a heartbeat line every 30s so the + // dialog always shows which step is in progress and that the worker is + // still alive (the RPC call itself may run for several minutes). + emit('登录终端已打开。请在浏览器中完成 GitHub 授权,控制台会自动检测结果(最多等待 8 分钟)…'); + const deadline = Date.now() + 8 * 60000; + let lastWaitEmitAt = 0; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 4000)); + if (Date.now() - lastWaitEmitAt >= 30000) { + lastWaitEmitAt = Date.now(); + const waitedMin = Math.max(0, Math.round((8 * 60000 - (deadline - Date.now())) / 60000)); + emit(`仍在等待浏览器登录…(已等待约 ${waitedMin} 分钟 / 最多 8 分钟)`); + } + try { + const status = await spawnLoopx({ argv: [gh], env: envOverlay }, ['auth', 'status'], { timeoutMs: 15000 }); + if (status.code === 0) { + let login = null; + try { + const user = await spawnLoopx({ argv: [gh], env: envOverlay }, ['api', 'user', '-q', '.login'], { timeoutMs: 20000 }); + if (user.code === 0) login = String(user.stdout || '').trim() || null; + } catch (_) {} + emit(`登录完成${login ? `:${login}` : ''}`); + return { ok: true, login }; + } + } catch (_) { /* still waiting */ } + } + return { + ok: false, + error: '等待浏览器登录超时(8 分钟)。请在弹出的窗口确认登录后重试,或改用方式二粘贴 Token。', + }; + }, + + // Publish the current fix branch as a PR through the user's fork: ensure + // the fork exists (create it when missing), push the branch, then open a + // pull request against the upstream default branch. When issueUrl is given, + // the PR is composed properly: title binds the issue, body links the issue + // with a one-line description, an analysis of the cause and the solution + // (generated by the UI and passed as `analysis`), and the branch's commit + // subjects — all tagged [bitfun-loopx] for countability. + async 'loopx.publishPr'({ + projectDir = null, goalId = null, token = null, title = '', body = '', branch: requestedBranch = null, + issueUrl = null, analysis = null, + } = {}) { + if (!projectDir) throw new Error('loopx.publishPr: projectDir is required'); + if (!token) { + // No configured PAT: fall back to the local GitHub CLI credential so a + // machine that ran `gh auth login` publishes without a pasted token. + token = await ghCliToken(); + if (!token) throw new Error('loopx.publishPr: token is required (configure one in the console, or run gh auth login)'); + } + dbgWorker('publishPr:start', `goalId=${goalId || ''} branch=${requestedBranch || ''} issueUrl=${issueUrl || ''}`); + let branch = (await gitRun(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim(); + if (!branch || branch === 'HEAD') throw new Error('publishPr: the checkout is in detached HEAD state'); + // Prefer the branch named by the gate item; keep HEAD when unknown. + if (requestedBranch && requestedBranch !== branch) { + try { + await gitRun(projectDir, ['rev-parse', '--verify', `refs/heads/${requestedBranch}`], 20000); + branch = requestedBranch; + } catch (_) { + dbgWorker('publishPr:branchFallback', `${requestedBranch} -> HEAD`); + } + } + const origin = (await gitRun(projectDir, ['remote', 'get-url', 'origin'])).stdout.trim(); + const repoMatch = origin.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i); + if (!repoMatch) throw new Error(`publishPr: cannot parse repository from origin "${origin}"`); + const owner = repoMatch[1]; + const repo = repoMatch[2].replace(/\.git$/i, ''); + const user = await githubApiRequest('/user', { token }); + const login = String((user && user.login) || '').trim(); + if (!login) throw new Error('publishPr: GitHub token did not resolve a login'); + + // ── PR content composition: bind the issue, describe the problem and the + // changes. The UI's title/body stay as the fallback for non-issue goals. + let finalTitle = title; + let finalBody = body; + let composedSections = null; // sections with a __COMMITS__ slot + let composedSolution = null; + const issueMatch = String(issueUrl || '').match(/\/(?:issues|pull)\/(\d+)/); + if (issueMatch) { + const issueNumber = Number(issueMatch[1]); + let issueInfo = null; + try { + issueInfo = await githubApiRequest(`/repos/${owner}/${repo}/issues/${issueNumber}`, { token }); + } catch (_) { /* compose without the live issue body */ } + const issueTitle = (issueInfo && issueInfo.title) || ''; + const titleSuffix = issueTitle + ? `Fix #${issueNumber}: ${String(issueTitle).slice(0, 80)}` + : `Fix #${issueNumber}`; + finalTitle = `${PR_TITLE_PREFIX}${titleSuffix}`; + composedSections = [ + `Fixes #${issueNumber}`, + `## 相关 Issue\n\nhttps://github.com/${owner}/${repo}/issues/${issueNumber}${issueTitle ? ` — ${issueTitle}` : ''}`, + ]; + const cause = analysis && typeof analysis.cause === 'string' && analysis.cause.trim() ? analysis.cause.trim() : null; + composedSolution = analysis && typeof analysis.solution === 'string' && analysis.solution.trim() ? analysis.solution.trim() : null; + if (cause) composedSections.push(`## 问题原因\n\n${cause}`); + composedSections.push('__COMMITS__', PR_BODY_MARKER); + } + + // Fork: reuse an existing fork; create and wait when absent. + let fork = await githubApiRequest(`/repos/${login}/${repo}`, { token, allow404: true }); + if (!fork) { + dbgWorker('publishPr:forking', `${owner}/${repo} -> ${login}/${repo}`); + await githubApiRequest(`/repos/${owner}/${repo}/forks`, { token, method: 'POST', jsonBody: {} }); + for (let i = 0; i < 30; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 4000)); + fork = await githubApiRequest(`/repos/${login}/${repo}`, { token, allow404: true }); + if (fork) break; + } + if (!fork) throw new Error('publishPr: the fork did not become ready within 2 minutes'); + } + + // Push the fix branch to the user's fork. The token rides the push URL + // for this single command; nothing is written to git config. + const pushUrl = `https://x-access-token:${token}@github.com/${login}/${repo}.git`; + dbgWorker('publishPr:push', `${login}/${repo}@${branch}`); + try { + await gitRun(projectDir, ['push', pushUrl, `HEAD:refs/heads/${branch}`], 300000); + } catch (err) { + throw new Error(String(err.message || err).split(token).join('***')); + } + + // Open the PR against the upstream default branch. + const upstream = await githubApiRequest(`/repos/${owner}/${repo}`, { token }); + const base = (upstream && upstream.default_branch) || 'main'; + if (composedSections) { + // Only the branch's OWN commits (merge-base..HEAD): a raw `git log -15 + // HEAD` drags in unrelated upstream history (docs/ci commits inherited + // from main) and buries the actual fix under noise. + let subjects = []; + let changedFiles = []; + let statLine = ''; + try { + let range = null; + for (const ref of [base, `origin/${base}`, 'main', 'master']) { + try { + const mb = await gitRun(projectDir, ['merge-base', ref, 'HEAD'], 20000); + const sha = String(mb.stdout || '').trim(); + if (sha) { range = `${sha}..HEAD`; break; } + } catch (_) { /* try the next base ref */ } + } + const log = await gitRun(projectDir, ['log', '-15', '--no-merges', '--pretty=format:%s', range || 'HEAD'], 30000); + subjects = String(log.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + if (range) { + const names = await gitRun(projectDir, ['diff', '--name-only', range], 30000); + changedFiles = String(names.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + const stat = await gitRun(projectDir, ['diff', '--shortstat', range], 30000); + statLine = String(stat.stdout || '').trim(); + } + } catch (_) { /* omit the commit/file list */ } + const solutionLine = composedSolution ? `${composedSolution}\n` : ''; + const commitLines = subjects.map((s) => `- ${s}`).join('\n'); + const slot = composedSections.indexOf('__COMMITS__'); + const parts = []; + if (solutionLine || commitLines) { + parts.push(`## 解决方案\n\n${solutionLine}${commitLines}`.trim()); + } + if (changedFiles.length) { + parts.push(`## 涉及文件${statLine ? `(${statLine})` : ''}\n\n${changedFiles.map((f) => `- ${f}`).join('\n')}`); + } + composedSections[slot] = parts.join('\n\n'); + finalBody = composedSections.filter(Boolean).join('\n\n'); + } + let pr; + try { + pr = await githubApiRequest(`/repos/${owner}/${repo}/pulls`, { + token, method: 'POST', + jsonBody: { title: finalTitle, head: `${login}:${branch}`, base, body: finalBody }, + }); + } catch (err) { + // A PR for this branch may already exist (publish retry): reuse it. + const existing = await githubApiRequest( + `/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${login}:${branch}`)}`, + { token }, + ); + const found = Array.isArray(existing) && existing[0]; + if (!found) throw err; + pr = found; + } + const prUrl = pr && pr.html_url ? pr.html_url : ''; + if (!prUrl) throw new Error('publishPr: GitHub did not return a PR url'); + dbgWorker('publishPr:done', `pr=${prUrl}`); + return { + ok: true, + prUrl, + prNumber: pr.number ?? null, + branch, + forkUrl: (fork && fork.html_url) || `https://github.com/${login}/${repo}`, + }; + }, + + // Creates (mode 'new') or steers (mode 'guide') a goal. Returns immediately + // and reports through taskIntake:progress / taskIntake:done events — a batch + // of issues takes minutes and must not queue behind the RPC mutex. + async 'loopx.taskIntake'({ + argvPrefix = null, + srcDir = null, + projectDir = null, + objective, + agentId, + mode = 'new', + goalId = null, + autoClone = false, + issues = null, // [{url, number, title}] — pre-confirmed selection from the UI + } = {}) { + const text = String(objective || '').trim(); + if (!projectDir && !autoClone) throw new Error('loopx.taskIntake: a local project directory is required (or enable autoClone)'); + dbgWorker('taskIntake:start', `mode=${mode} autoClone=${autoClone} projectDir=${projectDir || '(none)'} text=${text.slice(0, 80)}`); + if (!text) throw new Error('loopx.taskIntake: objective is required'); + if (text.length > 4000) throw new Error('loopx.taskIntake: objective is too long (max 4000 characters)'); + if (!agentId) throw new Error('loopx.taskIntake: agentId is required'); + if (mode === 'guide' && !goalId) throw new Error('loopx.taskIntake: mode "guide" requires goalId'); + + const { refs, unsupported } = githubReferences(text); + if (unsupported.length) { + return { + ok: false, + code: 'unsupported_github_path', + url: unsupported[0].url, + error: `Unsupported GitHub link: ${unsupported[0].url}. Paste an issue, a pull request, the repository home, or its issues list.`, + }; + } + if (!refs.length) { + return { + ok: false, + code: 'unsupported_input', + error: 'Paste a GitHub issue, pull request, repository, or issues-list link.', + }; + } + const listRefs = refs.filter((ref) => ref.kind === 'issues-list'); + const requestedRepos = [...new Set(refs.map((ref) => ref.repo))]; + const projectRepo = projectGithubRepository(projectDir); + if (requestedRepos.length > 1) { + return { + ok: false, + code: 'multiple_repositories', + error: 'One LoopX goal must target a single local repository. Split links from different repositories into separate tasks.', + requestedRepos, + }; + } + if (requestedRepos.length === 1 && projectRepo && requestedRepos[0] !== projectRepo) { + return { + ok: false, + code: 'repository_mismatch', + error: `The selected checkout is ${projectRepo}, but the task targets ${requestedRepos[0]}.`, + requestedRepo: requestedRepos[0], + projectRepo, + }; + } + if (requestedRepos.length === 1 && !projectRepo && projectDir) { + return { + ok: false, + code: 'repository_unverified', + error: `The selected directory is not a recognizable checkout of ${requestedRepos[0]} (no GitHub remote found).`, + requestedRepo: requestedRepos[0], + projectRepo: null, + }; + } + if (requestedRepos.length === 1 && !projectDir && !autoClone) { + return { + ok: false, + code: 'project_required', + error: `No local project directory is selected for ${requestedRepos[0]}; enable auto-clone or select a checkout.`, + requestedRepo: requestedRepos[0], + }; + } + + const repoLabel = requestedRepos[0] || projectRepo || null; + let issueList = Array.isArray(issues) && issues.length + ? issues.filter((issue) => issue && issue.url) + : refs.filter((ref) => ref.kind === 'issue' || ref.kind === 'pr') + .map((ref) => ({ url: ref.url, number: ref.number, title: `#${ref.number}` })); + + const targetGoalId = mode === 'guide' ? goalId : uniqueGoalId(projectDir, text, refs); + const intakeKind = listRefs.length ? 'issues-list' + : (issueList.length > 1 ? 'issues' : (issueList.length === 1 ? 'issue' : (refs.length ? 'repository' : 'goal'))); + const emit = (stage, extra = {}) => { + global.rpcEmit('taskIntake:progress', { goalId: targetGoalId, mode, stage, ...extra }); + }; + + (async () => { + const written = []; + const issueResults = []; + const skippedDuplicates = []; + let failure = null; + // Which stage failed matters: after bootstrap+register succeed the goal + // EXISTS, and reporting 'bootstrap_failed' would invite a retry that + // mints a duplicate goal. Track creation separately from later stages. + let goalCreated = mode === 'guide'; // guide targets an existing goal + let failedStage = 'bootstrap'; + let workingDir = projectDir; + try { + if (!workingDir && autoClone && requestedRepos.length === 1) { + emit('clone', { detail: 'start' }); + failedStage = 'clone'; + dbgWorker('taskIntake:clone:start', requestedRepos[0]); + workingDir = await cloneRepository(requestedRepos[0], (extra) => emit('clone', extra)); + dbgWorker('taskIntake:clone:done', workingDir); + ensureCommitTrailerHook(workingDir); + } + // issues-list / repository URL reaching intake without a UI-confirmed + // selection (standalone callers): expand it here. + const expandRepo = listRefs.length ? listRefs[0].repo + : (!issueList.length && refs.some((ref) => ref.kind === 'repository') ? repoLabel : null); + if (expandRepo && !issueList.length) { + emit('expand'); + failedStage = 'expand'; + const fetched = await fetchOpenIssues(expandRepo); + issueList = fetched.issues.map((issue) => ({ url: issue.url, number: issue.number, title: issue.title })); + } + + if (mode === 'new') { + emit('bootstrap'); + failedStage = 'bootstrap'; + const bootstrap = await runJson(argvPrefix, workingDir, [ + 'bootstrap', '--project', workingDir, '--goal-id', targetGoalId, + '--objective', text, + '--adapter-kind', 'read_only_project_map_v0', + '--adapter-status', 'connected-read-only', + '--no-onboarding-scan', '--codex-app-heartbeat', 'ask', + // The intake confirmation sheet is the user's consent to fix this + // repository: pre-grant the "write" scope so the agent can edit + // code without a mid-run approval gate (publish/PR stays gated). + '--write-scope', 'write', + ], { srcDir, timeoutMs: 90000 }); + if (bootstrap.result.code !== 0 || bootstrap.payload?.ok === false) { + throw new Error(bootstrap.payload?.error || bootstrap.result.stderr.trim() || 'loopx bootstrap failed'); + } + } + + // Registration runs for BOTH modes: todo add --claimed-by rejects + // agents not registered on the target goal, and in guide mode nothing + // guarantees the chosen agent is registered there. It is idempotent. + emit('register'); + failedStage = 'register'; + const registration = await runJson(argvPrefix, workingDir, [ + 'register-agent', '--goal-id', targetGoalId, '--agent-id', agentId, '--execute', + ], { srcDir, timeoutMs: 60000 }); + if (registration.result.code !== 0 || registration.payload?.ok === false) { + throw new Error(registration.payload?.error || registration.result.stderr.trim() || 'agent registration failed'); + } + goalCreated = true; + + if (issueList.length === 1) { + // Single issue: the full workflow-plan path yields ordered, + // classed todos (branch plan, validation, PR readiness). A + // re-pasted issue skips the write entirely. + if (await hasOpenIssueTodo(argvPrefix, srcDir, workingDir, targetGoalId, issueList[0].url)) { + skippedDuplicates.push(issueList[0]); + issueResults.push({ url: issueList[0].url, ok: true, skippedDuplicate: true, written: [] }); + } else { + emit('plan', { current: 1, total: 1, detail: issueList[0].url }); + failedStage = 'plan'; + const intake = await planIssueIntake({ argvPrefix, srcDir, projectDir: workingDir, url: issueList[0].url }); + const result = intake.ok + ? await writePlannedTodos({ argvPrefix, srcDir, projectDir: workingDir, goalId: targetGoalId, agentId, intake }) + : intake; + issueResults.push({ url: issueList[0].url, ...result }); + written.push(...(result.written || [])); + } + } else if (issueList.length > 1) { + // Batch: one intake todo per issue. Per-issue workflow-plan happens + // inside the agent's own turns — planning N issues up front would + // take minutes and burn the anonymous GitHub quota. + failedStage = 'todos'; + for (let i = 0; i < issueList.length; i += 1) { + const issue = issueList[i]; + // Per-issue dedup: an open todo for this URL already exists — + // skip instead of writing a duplicate. + if (await hasOpenIssueTodo(argvPrefix, srcDir, workingDir, targetGoalId, issue.url)) { + skippedDuplicates.push(issue); + written.push({ ok: true, skippedDuplicate: true, actionKind: 'fix_issue', url: issue.url }); + continue; + } + emit('todos', { current: i + 1, total: issueList.length, detail: issue.url }); + const label = issue.title && issue.title !== `#${issue.number}` + ? `Fix GitHub issue #${issue.number}: ${issue.title}` + : `Fix GitHub issue #${issue.number}`; + const args = [ + 'todo', 'add', '--goal-id', targetGoalId, '--role', 'agent', + '--task-class', 'advancement_task', '--action-kind', 'fix_issue', + '--claimed-by', agentId, '--text', `[P1] ${label} (${issue.url})`, + ]; + if (repoLabel) args.push('--task-repository', `git:github.com/${repoLabel}`); + try { + const response = await runJson(argvPrefix, workingDir, args, { srcDir, timeoutMs: 60000 }); + const ok = response.result.code === 0 && response.payload?.ok !== false; + written.push({ + ok, + todoId: response.payload?.todo_id ?? null, + actionKind: 'fix_issue', + url: issue.url, + error: ok ? null : (response.payload?.error || response.result.stderr.slice(0, 200) || 'todo add failed'), + }); + } catch (err) { + written.push({ ok: false, todoId: null, actionKind: 'fix_issue', url: issue.url, error: String(err.message || err) }); + } + } + } else { + emit('todos', { current: 1, total: 1 }); + failedStage = 'todos'; + const args = [ + 'todo', 'add', '--goal-id', targetGoalId, '--role', 'agent', + '--task-class', 'advancement_task', '--action-kind', 'deliver_user_goal', + '--claimed-by', agentId, '--text', `[P0] ${text}`, + ]; + if (repoLabel) args.push('--task-repository', `git:github.com/${repoLabel}`); + const todo = await runJson(argvPrefix, workingDir, args, { srcDir, timeoutMs: 60000 }); + const ok = todo.result.code === 0 && todo.payload?.ok !== false; + written.push({ + ok, + todoId: todo.payload?.todo_id ?? null, + actionKind: 'deliver_user_goal', + error: ok ? null : (todo.payload?.error || todo.result.stderr.trim() || 'todo add failed'), + }); + } + + emit('refresh'); + failedStage = 'refresh'; + // Best-effort: the goal and todos already exist; a refresh hiccup + // must not be reported as a failed creation. + try { + await runJson(argvPrefix, workingDir, [ + 'refresh-state', '--goal-id', targetGoalId, '--project', workingDir, + ], { srcDir, timeoutMs: 60000 }); + } catch (_) {} + } catch (err) { + failure = String(err.message || err); + } + const ok = !failure && written.length > 0 && written.every((entry) => entry.ok) + && issueResults.every((entry) => entry.ok); + const okWritten = written.filter((entry) => entry.ok).length; + global.rpcEmit('taskIntake:done', { + ok, + code: ok ? 'created' + : (failure ? `${failedStage}_failed` : (okWritten > 0 ? 'todos_partial' : 'todo_write_failed')), + created: goalCreated, + mode, + goalId: targetGoalId, + objective: text, + intakeKind, + issueCount: issueList.length, + skippedDuplicates: skippedDuplicates.length, + writtenOk: okWritten, + repository: repoLabel, + projectDir: workingDir, + written, + issueResults, + error: failure || (ok ? null : (written.find((entry) => !entry.ok)?.error + || issueResults.find((entry) => !entry.ok)?.error + || 'task intake did not produce runnable todos')), + }); + })(); + return { started: true, goalId: targetGoalId, mode, intakeKind, issueCount: issueList.length }; + }, + + async 'loopx.listGoals'({ argvPrefix = null, projectDir = null, projectDirs = null } = {}) { + const t0 = Date.now(); + dbgWorker('listGoals:start', `projectDir=${projectDir || '(none)'} projectDirs=${Array.isArray(projectDirs) ? projectDirs.length : 0}`); + // Direction C: goals may live in several registries — the user's global + // registry, one per cloned/selected project directory, and (since the + // clone cache is stable across MiniApp re-imports) any repo in the cache + // that carries its own .loopx registry. Query each and merge by goalId. + const cacheRepos = []; + try { + const root = cloneCacheRoot(); + if (fs.existsSync(root)) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (fs.existsSync(path.join(root, entry.name, '.loopx', 'registry.json'))) { + cacheRepos.push(path.join(root, entry.name)); + } + } + } + } catch (_) {} + const dirs = [projectDir, ...(Array.isArray(projectDirs) ? projectDirs : []), ...cacheRepos] + .filter((dir) => typeof dir === 'string' && dir); + const uniqueDirs = [...new Set(dirs)]; + const registryPaths = uniqueDirs + .map((dir) => path.join(dir, '.loopx', 'registry.json')) + .filter((p) => fs.existsSync(p)); + const targets = registryPaths.length ? registryPaths : [null]; + + const agentsByGoal = {}; + const objectivesByGoal = {}; + const dirByGoal = {}; + for (const target of targets) { + // target is <dir>/.loopx/registry.json — strip both segments to get the + // project directory itself. + const dir = target ? path.dirname(path.dirname(target)) : null; + const { registry } = readRegistry(dir); + for (const goal of (registry && registry.goals) || []) { + const goalId = goal.goal_id || goal.id; + if (!goalId) continue; + const coordination = goal.coordination || {}; + agentsByGoal[goalId] = (coordination.registered_agents || []) + .map((a) => (typeof a === 'string' ? a : a.agent_id || a.id)) + .filter(Boolean); + objectivesByGoal[goalId] = readGoalObjective(dir, goal); + if (dir) dirByGoal[goalId] = dir; + } + } + + let lastOk = true; + const groups = {}; + for (const target of targets) { + const dir = target ? path.dirname(path.dirname(target)) : null; + // runJson injects --registry <dir>/.loopx/registry.json itself; adding + // one here would double the flag and make loopx reject the command. + const args = ['quota', 'status', '--scan-root', quotaScanRoot()]; + const { result, payload } = await runJson(argvPrefix, dir, args); + if (result.code !== 0) { + lastOk = false; + continue; + } + const source = (payload && payload.groups) || payload || {}; + if (source && typeof source === 'object') { + for (const [state, entries] of Object.entries(source)) { + if (!Array.isArray(entries)) continue; + if (!groups[state]) groups[state] = []; + groups[state].push(...entries); + } + } + } + const goals = []; + const seen = new Set(); + const pushGoal = (goalId, state, extra = {}) => { + if (!goalId || seen.has(goalId)) return; + seen.add(goalId); + goals.push({ + goalId, + state: state || null, + agents: agentsByGoal[goalId] || [], + objective: objectivesByGoal[goalId] || null, + projectDir: dirByGoal[goalId] || null, + ...extra, + }); + }; + // quota status groups goals by state; shapes vary by schema version, so + // walk any {state: [goal…]} mapping and any flat goals list defensively. + for (const [state, entries] of Object.entries(groups)) { + for (const entry of entries) { + if (typeof entry === 'string') pushGoal(entry, state); + else if (entry && typeof entry === 'object') { + pushGoal(entry.goal_id || entry.id, entry.state || state, { + waitingOn: entry.waiting_on ?? null, + status: entry.status ?? null, + }); + } + } + } + for (const goalId of Object.keys(agentsByGoal)) pushGoal(goalId, null); + // Archived console goals must never silently vanish from the board: a + // task whose runtime loopx moved into archived-goals (e.g. a delete the + // user did not mean to keep) is surfaced with state=archived so the UI + // can offer 恢复. Only the NEWEST archive per goal id is reported. + const activeIds = new Set(goals.map((g) => g.goalId)); + const archiveRoot = path.join(os.homedir(), '.codex', 'loopx', 'archived-goals'); + try { + if (fs.existsSync(archiveRoot)) { + const newest = new Map(); // goalId -> { stamp, name } + for (const entry of fs.readdirSync(archiveRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const m = entry.name.match(/^(bfx-.*)-(\d{8}T\d{6}Z)$/); + if (!m) continue; + const goalId = m[1]; + if (activeIds.has(goalId)) continue; + if (!newest.has(goalId) || m[2] > newest.get(goalId).stamp) { + newest.set(goalId, { stamp: m[2], name: entry.name }); + } + } + for (const [goalId, info] of newest) { + goals.push({ + goalId, + state: 'archived', + agents: [], + objective: null, + projectDir: null, + archived: true, + archiveDir: path.join(archiveRoot, info.name), + archiveName: info.name, + }); + } + } + } catch (_) {} + const registryPath = targets.length === 1 + ? (targets[0] || resolveRegistryPath(null)) + : (registryPaths[0] || null); + dbgWorker('listGoals:done', `ms=${Date.now() - t0} goals=${goals.length} lastOk=${lastOk}`); + return { ok: lastOk, registryPath, goals, raw: { groups } }; + }, + + async 'loopx.status'({ argvPrefix = null, projectDir = null, goalId = null, agentId = null } = {}) { + const args = ['status']; + if (goalId) args.push('--goal-id', goalId); + if (agentId) args.push('--agent-id', agentId); + const { result, payload } = await runJson(argvPrefix, projectDir, args); + return { ok: result.code === 0, payload, stderr: result.stderr }; + }, + + async 'loopx.shouldRun'({ argvPrefix = null, projectDir = null, goalId, agentId } = {}) { + if (!goalId) throw new Error('loopx.shouldRun: goalId is required'); + const t0 = Date.now(); + dbgWorker('shouldRun:start', `goalId=${goalId} projectDir=${projectDir || '(none)'}`); + const args = [ + 'quota', 'should-run', + '--goal-id', goalId, + '--runtime-profile', 'outer_controller', + '--include-scheduler-detail', + '--scan-root', quotaScanRoot(), + ]; + if (agentId) args.push('--agent-id', agentId); + const { result, payload } = await runJson(argvPrefix, projectDir, args); + dbgWorker('shouldRun:done', `ms=${Date.now() - t0} code=${result.code} hasPayload=${Boolean(payload)}`); + if (!payload) { + return { ok: false, error: result.stderr.trim() || 'loopx returned no JSON payload', raw: null }; + } + return { + ok: result.code === 0, + shouldRun: payload.should_run ?? null, + decision: payload.decision ?? null, + state: payload.state ?? null, + effectiveAction: payload.effective_action ?? null, + reason: payload.reason ?? null, + recommendedAction: payload.recommended_action ?? null, + waitingOn: payload.waiting_on ?? null, + scheduler: normalizeScheduler(payload.scheduler_hint), + raw: payload, + }; + }, + + // Builds the complete prompt for one execution turn: loopx's own + // heartbeat-prompt task body (state machine contract, todo discipline, + // capability scopes) prefixed with the repo/registry binding preamble. + // The UI feeds this to the HOST's agent (app.agent.run) — BitFun itself is + // the execution engine; no external CLI host or user configuration. + async 'loopx.turnPrompt'({ + argvPrefix = null, srcDir = null, projectDir = null, goalId, agentId, + } = {}) { + if (!goalId || !agentId) throw new Error('loopx.turnPrompt: goalId and agentId are required'); + if (!projectDir) throw new Error('loopx.turnPrompt: projectDir is required'); + dbgWorker('turnPrompt:start', `goalId=${goalId}`); + // Re-assert the commit marker hook before every turn (cheap + idempotent): + // the clone may have been re-fetched or its hooks wiped between turns. + ensureCommitTrailerHook(projectDir); + const { result, payload } = await runJson(argvPrefix, projectDir, [ + 'heartbeat-prompt', '--goal-id', goalId, '--agent-id', agentId, + '--runtime-profile', 'outer_controller', '--compact', + ], { srcDir, timeoutMs: 180000 }); + dbgWorker('turnPrompt:done', `code=${result.code} hasBody=${Boolean(payload && payload.task_body)}`); + const body = payload && typeof payload.task_body === 'string' ? payload.task_body : null; + if (result.code !== 0 || payload?.ok === false || !body) { + return { + ok: false, + error: payload?.error || result.stderr.trim() || 'heartbeat-prompt produced no task body', + }; + } + // loopx's prompt vocabulary is codex-lineage ("codex" = its label for the + // agent execution lane; in outer_controller mode that lane IS the BitFun + // host agent). Strip the one codex-specific instruction so the BitFun + // agent never reads a reference to a "Codex session" it does not have — + // keep the intent (do not re-ask granted permissions) in host terms. + const sanitized = body.replace( + /\bDo not ask for permissions when the current Codex session is already trusted\./g, + 'Do not re-ask for permissions BitFun has already granted in this session.', + ); + return { ok: true, prompt: turnPreamble({ projectDir, goalId, agentId }) + sanitized }; + }, +}; diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime.rs b/src/crates/contracts/product-domains/src/miniapp/runtime.rs index 5d1e127c1e..e8e6af9f8d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime.rs @@ -81,10 +81,37 @@ pub fn detect_runtime_with_probe<P: MiniAppRuntimeProbe + ?Sized>( version, }); } + // Windows: the `which`-style PATH search treats any existing file + // without an extension as an executable candidate, so an npm-style + // extensionless shim (a POSIX script that CreateProcess cannot run) + // can beat the real launcher in the same directory (e.g. `bun` vs + // `bun.cmd`). Retry the same candidate with the standard executable + // suffixes before giving up on this runtime. + #[cfg(windows)] + { + for ext in [".exe", ".cmd", ".bat", ".com"] { + let candidate = append_suffix(&path, ext); + if let Some(version) = probe.runtime_version(&candidate) { + return Some(DetectedRuntime { + kind, + path: candidate, + version, + }); + } + } + } } None } +/// Append an extension suffix to a Windows candidate path, e.g. `bun` -> `bun.cmd`. +#[cfg(windows)] +fn append_suffix(path: &Path, ext: &str) -> PathBuf { + let mut os = path.as_os_str().to_owned(); + os.push(ext); + PathBuf::from(os) +} + pub fn runtime_lookup_order() -> &'static [&'static str] { &["bun", "node"] } @@ -294,4 +321,27 @@ mod tests { assert_eq!(detected.kind, RuntimeKind::Node); assert_eq!(detected.path, node); } + + #[cfg(windows)] + #[test] + fn detector_recovers_extensionless_shim_via_pathext_suffixes() { + // npm installs a POSIX `bun` shim (no extension, not executable by + // CreateProcess) alongside `bun.cmd`; the PATH search can return the + // shim first. The detector must retry the executable suffixes and + // still pick bun over node. + let mut probe = FakeProbe::default(); + let shim = PathBuf::from(r"C:\Program Files\bun\bun"); + let real = PathBuf::from(r"C:\Program Files\bun\bun.cmd"); + let node = PathBuf::from(r"C:\Program Files\nodejs\node.exe"); + probe.path_hits.insert("bun".to_string(), shim); + probe.path_hits.insert("node".to_string(), node.clone()); + probe.versions.insert(real.clone(), "1.3.14".to_string()); + probe.versions.insert(node, "v24.0.0".to_string()); + + let detected = detect_runtime_with_probe(&probe).expect("bun should be detected"); + + assert_eq!(detected.kind, RuntimeKind::Bun); + assert_eq!(detected.path, real); + assert_eq!(detected.version, "1.3.14"); + } } diff --git a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs index b05152b9b6..ae9c8bd9b6 100644 --- a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs +++ b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs @@ -4,7 +4,7 @@ //! Why this exists //! --------------- //! The original MiniApp design routed every `app.*` call through a Bun/Node Worker -//! (`resources/worker_host.js`). That gives apps a real V8 sandbox for arbitrary +//! (`resources/worker_host.cjs`). That gives apps a real V8 sandbox for arbitrary //! `worker.js` code, but it forces every app — even ones that just want to shell out //! to `git` — to depend on having Bun or Node installed and a worker runtime online. //! @@ -19,7 +19,7 @@ //! pool when the app has `node.enabled = true`. `storage.*` is served by the manager //! directly from the Tauri command layer regardless of node.enabled. //! -//! Permission enforcement here mirrors `worker_host.js` exactly so the security +//! Permission enforcement here mirrors `worker_host.cjs` exactly so the security //! contract is identical regardless of the routing path. use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; @@ -105,7 +105,7 @@ pub type MiniAppHostDispatchResult<T> = Result<T, MiniAppHostDispatchError>; /// Dispatch a framework-primitive RPC on the host. /// /// `perms` and the path arguments are used to build a permission policy with the -/// same shape `worker_host.js` consumes, then the namespace-specific handler is +/// same shape `worker_host.cjs` consumes, then the namespace-specific handler is /// invoked. pub async fn dispatch_host( perms: &MiniAppPermissions, @@ -169,7 +169,7 @@ fn canonicalize_best_effort(p: &Path) -> PathBuf { } /// A target path is allowed when its canonicalized form starts with one of the -/// canonicalized scope roots. Mirrors the worker_host.js check, but uses real +/// canonicalized scope roots. Mirrors the worker_host.cjs check, but uses real /// canonicalization so e.g. `/tmp/foo` on macOS (`/private/tmp/foo`) matches a /// `/tmp` scope after both sides resolve symlinks. fn path_allowed(policy: &Value, target: &Path, mode: FsAccessMode) -> bool { @@ -410,7 +410,7 @@ async fn dispatch_shell( } }; cmd.current_dir(&plan.cwd); - // Match worker_host.js: never let git prompt for credentials, force C locale so + // Match worker_host.cjs: never let git prompt for credentials, force C locale so // stdout parsing is deterministic. for (key, value) in shell_exec_default_env() { cmd.env(key, value); @@ -433,7 +433,7 @@ async fn dispatch_shell( let code = output.status.code().unwrap_or(-1); if !output.status.success() { - // Mirror worker_host.js (which uses Node `execAsync`, rejecting on non-zero + // Mirror worker_host.cjs (which uses Node `execAsync`, rejecting on non-zero // exit with stderr in the message). let msg = if !stderr.trim().is_empty() { stderr.trim().to_string() diff --git a/src/crates/services/services-integrations/src/miniapp/worker.rs b/src/crates/services/services-integrations/src/miniapp/worker.rs index 1c8ededb03..380928d906 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker.rs @@ -18,6 +18,10 @@ type PendingResponseSender = oneshot::Sender<JsWorkerResponse>; type PendingResponseMap = HashMap<String, PendingResponseSender>; pub type MiniAppWorkerEventFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>; +/// How long to wait for the worker host's `__ready` handshake before treating +/// the spawn as failed. +const BOOT_READY_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Debug, Clone)] pub struct MiniAppWorkerEvent { pub app_id: String, @@ -40,11 +44,19 @@ pub struct JsWorker { } impl JsWorker { - /// Spawn Worker process: `runtime_path worker_host_path '<policy_json>'` with cwd = app_dir. + /// Spawn Worker process: `runtime_path worker_host_path` with cwd = app_dir. /// The `app_id` is used as the source identifier when emitting worker events. + /// `resource_dir`, when present, is exported as `BITFUN_RESOURCE_DIR` so + /// workers can find host-bundled sidecar binaries (e.g. the bitfun-loopx + /// compiled loopx CLI). + /// The permission policy JSON travels via the `BITFUN_WORKER_POLICY` + /// environment variable, not an argv argument: Windows argv parsing (and + /// Bun's in particular) does not round-trip multi-line JSON reliably, and + /// the worker fails to boot with a truncated policy. pub async fn spawn( runtime: &DetectedRuntime, worker_host_path: &Path, + resource_dir: Option<&Path>, app_dir: &Path, policy_json: &str, app_id: String, @@ -52,14 +64,19 @@ impl JsWorker { ) -> Result<Self, String> { let exe = runtime.path.to_string_lossy(); let host = worker_host_path.to_string_lossy(); - let mut child = bitfun_services_core::process_manager::create_tokio_command(&*exe) + let mut command = bitfun_services_core::process_manager::create_tokio_command(&*exe); + command .arg(&*host) - .arg(policy_json) .current_dir(app_dir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true) + .env("BITFUN_WORKER_POLICY", policy_json); + if let Some(dir) = resource_dir { + command.env("BITFUN_RESOURCE_DIR", dir); + } + let mut child = command .spawn() .map_err(|e| format!("Failed to spawn JS Worker: {}", e))?; @@ -75,10 +92,74 @@ impl JsWorker { .as_millis() as i64, )); + // Boot handshake: the worker host emits {"id":"__ready",...} on stderr + // once the app worker has loaded. Wait for it with a short deadline so + // a worker that crashes at startup (runtime misdetection, syntax error, + // missing dependency) fails fast instead of hanging every RPC until the + // caller's — possibly very large — timeout. + let deadline = std::time::Instant::now() + BOOT_READY_TIMEOUT; + let mut reader = BufReader::new(stderr); + let mut boot_log = String::new(); + let mut ready = false; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + break; + } + let mut line = String::new(); + let read = tokio::time::timeout(remaining, reader.read_line(&mut line)).await; + match read { + Ok(Ok(0)) => break, // EOF: the worker process is gone + Ok(Ok(_)) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.contains("\"id\":\"__ready\"") + || trimmed.contains("\"id\": \"__ready\"") + { + ready = true; + break; + } + if boot_log.len() < 2048 { + boot_log.push_str(trimmed); + boot_log.push('\n'); + } + } + Ok(Err(err)) => { + boot_log.push_str(&format!("stderr read error: {err}\n")); + break; + } + Err(_) => break, // timed out waiting for the ready handshake + } + } + if !ready { + let _ = child.start_kill(); + let exit = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(status) = child.try_wait().ok().flatten() { + return status.code(); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .ok() + .flatten(); + let detail = if boot_log.trim().is_empty() { + "worker exited without output".to_string() + } else { + boot_log.trim().to_string() + }; + return Err(format!( + "JS Worker failed to become ready (exit={:?}): {}", + exit, detail + )); + } + let pending_clone = pending.clone(); let last_activity_clone = last_activity.clone(); tokio::spawn(async move { - let reader = BufReader::new(stderr); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { if line.is_empty() { diff --git a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs index 2a6d91409a..d637a7df89 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs @@ -173,6 +173,9 @@ pub struct JsWorkerPool { runtime: DetectedRuntime, worker_host_path: PathBuf, miniapps_dir: PathBuf, + /// Directory of host-bundled sidecar resources, exported to every worker + /// as `BITFUN_RESOURCE_DIR` (None in host-less tests and minimal hosts). + resource_dir: Option<PathBuf>, event_sink: Option<SharedMiniAppWorkerEventSink>, } @@ -180,6 +183,7 @@ impl JsWorkerPool { pub fn new( miniapps_dir: PathBuf, worker_host_path: PathBuf, + resource_dir: Option<PathBuf>, event_sink: Option<SharedMiniAppWorkerEventSink>, ) -> MiniAppWorkerPoolResult<Self> { let runtime = detect_runtime().ok_or_else(|| { @@ -190,6 +194,7 @@ impl JsWorkerPool { Ok(Self::from_runtime( miniapps_dir, worker_host_path, + resource_dir, runtime, event_sink, )) @@ -198,6 +203,7 @@ impl JsWorkerPool { pub fn from_runtime( miniapps_dir: PathBuf, worker_host_path: PathBuf, + resource_dir: Option<PathBuf>, runtime: DetectedRuntime, event_sink: Option<SharedMiniAppWorkerEventSink>, ) -> Self { @@ -209,6 +215,7 @@ impl JsWorkerPool { runtime, worker_host_path, miniapps_dir, + resource_dir, event_sink, } } @@ -308,6 +315,7 @@ impl JsWorkerPool { let worker = JsWorker::spawn( &self.runtime, &self.worker_host_path, + self.resource_dir.as_deref(), app_dir, policy_json, app_id.to_string(), @@ -537,6 +545,7 @@ mod tests { let pool = JsWorkerPool::from_runtime( miniapps_dir, PathBuf::from("worker-host.js"), + None, DetectedRuntime { kind: RuntimeKind::Node, path: PathBuf::from("node"), @@ -577,6 +586,7 @@ mod tests { let pool = JsWorkerPool::from_runtime( miniapps_dir, PathBuf::from("worker-host.js"), + None, DetectedRuntime { kind: RuntimeKind::Node, path: PathBuf::from("node"), diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss index dbc950e04c..8272b12aba 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss @@ -40,6 +40,36 @@ $scene-gutter: clamp(28px, 4vw, 64px); min-height: 0; } } + + // Non-desktop surfaces (server/web): explicit degrade notice instead of an + // empty gallery, telling the user where MiniApps actually run. + &__unsupported { + display: grid; + place-items: center; + flex: 1 1 auto; + min-height: 0; + padding: $size-gap-3; + background: var(--bf-appearance-token-color-bg-primary); + } + + &__unsupported-card { + max-width: 440px; + padding: $size-gap-4; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-base; + text-align: center; + } + + &__unsupported-title { + margin: 0 0 $size-gap-1; + font-size: 15px; + color: var(--bf-appearance-token-color-text-primary); + } + + &__unsupported-body { + margin: 0; + color: var(--bf-appearance-token-color-text-secondary); + } } @media (max-width: 560px) { diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx index 448a695c80..03411df1c7 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx @@ -6,6 +6,7 @@ import React, { Suspense, lazy, useState } from 'react'; import { Download, Store, UploadCloud } from 'lucide-react'; import { Tabs, TabPane } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; +import { isTauriRuntime } from '@/infrastructure/runtime'; import './MiniAppGalleryScene.scss'; const MiniAppGalleryView = lazy(() => import('./views/MiniAppGalleryView')); @@ -18,6 +19,22 @@ const MiniAppGalleryScene: React.FC = () => { const { t } = useI18n('scenes/miniapp'); const [activeTab, setActiveTab] = useState<MiniAppGalleryTab>('installed'); + // MiniApps run inside the desktop host (worker pool + built-in seeding live + // there). On server/web surfaces the scene degrades loudly instead of + // rendering an empty gallery. + if (!isTauriRuntime()) { + return ( + <div className="miniapp-gallery-scene" data-bf-scene="miniapp-gallery" data-bf-part="root"> + <div className="miniapp-gallery-scene__unsupported"> + <div className="miniapp-gallery-scene__unsupported-card"> + <h2 className="miniapp-gallery-scene__unsupported-title">{t('unsupported.title')}</h2> + <p className="miniapp-gallery-scene__unsupported-body">{t('unsupported.body')}</p> + </div> + </div> + </div> + ); + } + return ( <div className="miniapp-gallery-scene" data-bf-scene="miniapp-gallery" data-bf-part="root"> <Tabs diff --git a/src/web-ui/src/locales/en-US/scenes/miniapp.json b/src/web-ui/src/locales/en-US/scenes/miniapp.json index 52f488d02e..d986e4669f 100644 --- a/src/web-ui/src/locales/en-US/scenes/miniapp.json +++ b/src/web-ui/src/locales/en-US/scenes/miniapp.json @@ -257,5 +257,9 @@ "cancel": "Back to preview", "confirm": "Apply and grant permissions" } + }, + "unsupported": { + "title": "MiniApps are desktop-only", + "body": "Built-in MiniApps and the market run in the desktop BitFun app. Open the desktop app and use the MiniApp entry at the bottom-left of the navigation." } } diff --git a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json index 95b3989326..31fd8c39e0 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json @@ -257,5 +257,9 @@ "confirm": "应用并授予权限" }, "chatLoading": "正在打开编辑会话..." + }, + "unsupported": { + "title": "MiniApp 仅在桌面版可用", + "body": "内置小应用与市场需要桌面版 BitFun 才能运行。请打开桌面版 BitFun,点击左下角的「小应用」入口使用。" } } diff --git a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json index bc0fb2d23b..8dfc1e2a26 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json @@ -257,5 +257,9 @@ "confirm": "套用並授予權限" }, "chatLoading": "正在開啟編輯會話..." + }, + "unsupported": { + "title": "MiniApp 僅在桌面版可用", + "body": "內建小應用與市場需要桌面版 BitFun 才能執行。請開啟桌面版 BitFun,點擊左下角的「小應用」入口使用。" } }