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/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index dd37697889..df0378c46b 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -154,6 +154,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option String { - format!("miniapp-agent-{}-{}", app_id, sequence) + // The process-local sequence resets on restart while a reused hidden + // session keeps its persisted dialog_turn_ids. A restarted host would + // otherwise regenerate "miniapp-agent-{app_id}-1" and collide with a turn + // already recorded on that session ("Dialog turn already exists"). Anchor + // the id to wall-clock time so it stays unique across restarts. + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("miniapp-agent-{}-{}-{}", app_id, sequence, stamp) } pub fn agent_run_id_from_request( @@ -474,9 +483,10 @@ mod tests { agent_run_id_from_request("app-1", Some(" run-1 "), 9), "run-1" ); - assert_eq!( - agent_run_id_from_request("app-1", Some(" "), 9), - "miniapp-agent-app-1-9" + let generated = agent_run_id_from_request("app-1", Some(" "), 9); + assert!( + generated.starts_with("miniapp-agent-app-1-9-"), + "expected a restart-unique run id, got: {generated}" ); let plan = build_agent_submission_plan( diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 693aec8c4d..a3835f0f3d 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: 3, + 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..0465ccb5b0 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html @@ -0,0 +1,243 @@ + + + + + + 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

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

+

+
+ + +
+
+
+ +
+ + 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..d965fa70f0 --- /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": 3, + "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..ba697b5585 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css @@ -0,0 +1,1010 @@ +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; width: 100%; height: 100%; } +/* The html canvas itself must follow the theme, not the webview's default + (white): a white canvas behind the visibility gate is the first of the two + launch flashes. Body already paints --lx-bg; html matches it so there is no + white frame before the theme vars land. */ +html { background: var(--lx-bg); } +[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); + /* Craft tokens: one easing curve and two durations keep every hover/press + micro-interaction in the same hand; radius-sm tightens nested surfaces. */ + --lx-ease: cubic-bezier(.2, .6, .2, 1); + --lx-dur-fast: 110ms; + --lx-dur: 170ms; + --lx-radius-sm: 6px; +} + +/* Light-theme fallbacks: the host injects --bitfun-* tokens asynchronously, so + a light-mode session would otherwise paint the dark fallback (#111214) for a + frame and flash black. data-bf-appearance-mode is set synchronously at + compile time, so these light fallbacks apply from the very first paint. */ +[data-bf-appearance-mode="light"] { + --lx-bg: var(--bitfun-bg, #f3f3f5); + --lx-surface: var(--bitfun-bg-secondary, #ffffff); + --lx-surface-muted: var(--bitfun-bg-tertiary, #f1f2f5); + --lx-elevated: var(--bitfun-bg-elevated, #ffffff); + --lx-surface-2: var(--bitfun-element-bg, rgba(15,23,42,.045)); + --lx-hover: var(--bitfun-element-hover, rgba(15,23,42,.07)); + --lx-border: var(--bitfun-border-subtle, rgba(15,23,42,.10)); + --lx-border-strong: var(--bitfun-border, rgba(15,23,42,.16)); + --lx-text: var(--bitfun-text, #1f2328); + --lx-text-soft: var(--bitfun-text-secondary, #4b5563); + --lx-text-mute: var(--bitfun-text-muted, #6b7280); + --lx-accent: var(--bitfun-accent, #5b8def); + --lx-accent-hover: var(--bitfun-accent-hover, #477de8); + --lx-ok: var(--bitfun-success, #1f9d61); + --lx-warn: var(--bitfun-warning, #c07f1f); + --lx-error: var(--bitfun-error, #d64550); +} + +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); } +.goal-status--review { color: var(--lx-review); background: color-mix(in srgb, var(--lx-review) 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:新建 / 引导 模式 tab + 链接优先输入 + unfurl 预览。 */ +.composer__modes { + width: min(960px, 100%); margin: 0 auto 6px; display: flex; gap: 4px; + padding: 2px; border: 1px solid var(--lx-border); border-radius: 10px; + background: var(--lx-surface-muted); +} +.composer__mode { + flex: 1; min-height: 28px; padding: 3px 12px; border: 0; border-radius: 8px; + background: transparent; color: var(--lx-text-mute); font-size: 11.5px; cursor: pointer; +} +.composer__mode:hover { color: var(--lx-text); } +.composer__mode.is-active { + background: var(--lx-elevated); color: var(--lx-text); font-weight: 600; + box-shadow: 0 1px 3px color-mix(in srgb, var(--lx-text) 8%, transparent); +} +.composer__link-row { + display: flex; align-items: center; gap: 8px; padding: 10px 18px 4px; color: var(--lx-text-mute); +} +.composer__link-row svg { flex: 0 0 auto; } +.composer__link-row input { + flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; + color: var(--lx-text); font: 14px/1.5 var(--lx-sans); +} +.composer__link-row input::placeholder { color: var(--lx-text-mute); } +.composer__unfurl { + display: flex; align-items: center; gap: 6px; margin: 4px 18px 0; padding: 5px 9px; + border: 1px solid color-mix(in srgb, var(--lx-accent) 40%, var(--lx-border)); + border-radius: 8px; background: color-mix(in srgb, var(--lx-accent) 8%, transparent); + color: var(--lx-text-soft); font-size: 11.5px; +} +.composer__unfurl-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.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; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.composer__feedback--error { color: var(--lx-error); } +.composer__feedback--ok { color: var(--lx-ok); } +/* 任务创建进度 stepper:把 intake 固定阶段全列出来,用户看到总体进度。 */ +.intake-stepper { + display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; + width: min(960px, 100%); margin: 0 auto 6px; padding: 0 4px; font-size: 11px; +} +.intake-stepper__seg { display: inline-flex; align-items: center; gap: 5px; color: var(--lx-text-mute); } +.intake-stepper__seg.is-done { color: var(--lx-ok); } +.intake-stepper__seg.is-active { color: var(--lx-accent); font-weight: 600; } +.intake-stepper__mark { + display: inline-flex; align-items: center; justify-content: center; + width: 16px; height: 16px; border-radius: 50%; + border: 1px solid var(--lx-border-strong); font: 700 9px/1 var(--lx-mono); +} +.intake-stepper__seg.is-done .intake-stepper__mark { border-color: var(--lx-ok); color: var(--lx-ok); } +.intake-stepper__seg.is-active .intake-stepper__mark { border-color: var(--lx-accent); color: var(--lx-accent); animation: activity-pulse 1.4s ease-in-out infinite; } +.intake-stepper__sep { color: var(--lx-border-strong); } + +.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 selected task's live log frame (the 进行中 directory rail + was removed — in-progress goals now live in the composer's goal-picker). */ +.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; } +/* The log panel fills the run frame (no rail divider anymore). */ +.detail-panel { + position: relative; + flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; + min-height: 0; + 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); } +/* When the running-status bar is visible, lift the scroll-to-bottom button + above it so the two controls never overlap. */ +.detail-panel:has(#goal-detail-status:not([hidden])) .log-bottom { bottom: 46px; } +.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__body { flex: 1; min-height: 0; overflow-y: auto; } +/* Running status bar pinned to the panel's foot: shimmering label + clock. */ +.detail-panel__status { + flex: 0 0 auto; display: flex; align-items: center; gap: 8px; + padding: 7px 14px; border-top: 1px solid var(--lx-border); + background: var(--lx-surface); font-size: 14px; +} +.detail-panel__status-text { + display: inline-block; white-space: nowrap; font-weight: 600; + background: linear-gradient( + 90deg, + var(--lx-accent) 0%, var(--lx-accent) 40%, + color-mix(in srgb, var(--lx-accent) 42%, #ffffff) 50%, + var(--lx-accent) 60%, var(--lx-accent) 100% + ); + background-position: 100% 0; background-size: 250% 100%; + background-clip: text; color: transparent; + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + animation: lx-status-shimmer 1.8s linear infinite; +} +.detail-panel__status-clock { font-size: 11px; font-weight: 400; color: var(--lx-text-mute); } +/* The run/stop toggle sits at the far right of the running status bar. */ +.detail-panel__status .btn { margin-left: auto; flex: 0 0 auto; } +/* Paused state: no shimmer, muted text — it is a resting slot, not a live dive. */ +.detail-panel__status--paused .detail-panel__status-text { + background: none; color: var(--lx-text-mute); + -webkit-text-fill-color: currentColor; animation: none; +} +/* Idle state: the turn is "running" but produced no output for a while — warn + instead of shimmering like everything is fine. */ +.detail-panel__status--idle .detail-panel__status-text { + background: none; color: var(--lx-warn); + -webkit-text-fill-color: currentColor; animation: none; +} +.detail-panel__status--idle .detail-panel__status-clock { color: var(--lx-warn); } +@keyframes lx-status-shimmer { to { background-position: 0 0; } } +@media (prefers-reduced-motion: reduce) { + .detail-panel__status-text { background-position: 0 0; background-size: 100% 100%; animation: none; } +} +.dot--backlog { background: var(--lx-text-mute); } +.dot--active { background: var(--lx-ok); } +.dot--review { background: var(--lx-review); } +.dot--decisions { background: var(--lx-review); } +.dot--notices { background: var(--lx-text-mute); } +.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; } + +/* ══ 紧凑看板行(左列)────────────────────────────────────── */ +.board-row { + display: flex; align-items: flex-start; gap: 9px; width: 100%; + padding: 9px 10px; border: 1px solid var(--lx-border); border-radius: var(--lx-radius); + background: var(--lx-elevated); color: var(--lx-text); cursor: pointer; text-align: left; +} +.board-row:hover { border-color: var(--lx-border-strong); background: var(--lx-hover); } +.board-row.is-selected { + border-color: color-mix(in srgb, var(--lx-accent) 40%, var(--lx-border)); + background: var(--lx-surface-2); box-shadow: inset 2px 0 0 var(--lx-accent); +} +.board-row--decisions { border-left: 3px solid var(--lx-review); } +.board-row--notices { border-left: 3px solid var(--lx-text-mute); background: transparent; } +.board-row .dot { margin-top: 5px; } +.board-row__main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; } +.board-row__title { display: flex; align-items: center; gap: 7px; min-width: 0; } +.board-row__name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--lx-text); font-weight: 600; font-size: 12.5px; } +.board-row__badge { flex: 0 0 auto; padding: 1px 7px; border-radius: 999px; font-size: 10px; font-weight: 650; white-space: nowrap; } +.board-row__badge--decision { color: var(--lx-review); background: color-mix(in srgb, var(--lx-review) 12%, transparent); border: 1px solid color-mix(in srgb, var(--lx-review) 34%, var(--lx-border)); } +.board-row__badge--notice { color: var(--lx-text-mute); background: color-mix(in srgb, var(--lx-text-mute) 10%, transparent); border: 1px solid var(--lx-border); } +.board-row__actions { margin-left: auto; flex: 0 0 auto; display: inline-flex; } +.board-row__actions .goal-actions { margin-top: 0; gap: 4px; } +.board-row__actions .btn { opacity: .55; } +.board-row:hover .board-row__actions .btn { opacity: 1; } +.board-row__sub { display: flex; flex-direction: column; gap: 3px; min-width: 0; color: var(--lx-text-mute); font-size: 11px; } +.board-row__summary { display: flex; align-items: baseline; gap: 6px; min-width: 0; } +.board-row__summary-title { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--lx-text); } +.board-row__summary-preview { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--lx-text-mute); } +.board-row__summary-more { flex: 0 0 auto; color: var(--lx-text-mute); font: 600 10px/1 var(--lx-mono); } +.board-row__activity { color: var(--lx-text-soft); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.board-row__error { color: var(--lx-error); font: 10.5px/1.4 var(--lx-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.board-row__narration { color: var(--lx-text-soft); overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; overflow-wrap: anywhere; } +.board-row__decision { display: flex; align-items: center; gap: 6px; } +.board-row__decision-more { color: var(--lx-text-mute); font: 600 10px/1 var(--lx-mono); } + +.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--resolved { 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); text-decoration: line-through; } +.issue-chip--more { cursor: pointer; opacity: .85; } +/* Issue 总览看板(方案 A):状态总览条 + 分组小卡。 */ +.issue-board { margin-top: 8px; } +.issue-overview { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; } +.issue-overview__seg { + display: inline-flex; align-items: center; gap: 4px; + border: 1px solid var(--lx-border); background: transparent; + color: var(--lx-text-mute); font-size: 10.5px; line-height: 1; + padding: 3px 8px; border-radius: 999px; cursor: pointer; +} +.issue-overview__seg::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.issue-overview__seg--blocked { color: var(--lx-error); border-color: color-mix(in srgb, var(--lx-error) 45%, var(--lx-border)); } +.issue-overview__seg--deferred { color: var(--lx-text-mute); } +.issue-overview__seg--open { color: var(--lx-accent); border-color: color-mix(in srgb, var(--lx-accent) 45%, var(--lx-border)); } +.issue-overview__seg--resolved { color: var(--lx-ok); border-color: color-mix(in srgb, var(--lx-ok) 45%, var(--lx-border)); } +.issue-board__group { margin-top: 8px; color: var(--lx-text-mute); font-size: 11px; font-weight: 600; } +.issue-board__group:first-of-type { margin-top: 4px; } +.issue-board__details { margin-top: 6px; } +.issue-board__details summary { cursor: pointer; color: var(--lx-text-mute); font-size: 11px; font-weight: 600; } +.issue-board__details summary:hover { color: var(--lx-text-soft); } +.issue-board__items { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; } +.issue-card { + display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 6px; row-gap: 1px; + text-decoration: none; color: inherit; + border: 1px solid var(--lx-border); border-left: 3px solid var(--lx-border-strong); + border-radius: 6px; padding: 4px 8px; line-height: 1.45; +} +.issue-card:hover { border-color: var(--lx-border-strong); background: color-mix(in srgb, var(--lx-border) 30%, transparent); } +.issue-card__num { flex: 0 0 auto; font: 600 11px/1.45 var(--lx-mono); color: var(--lx-text-mute); } +.issue-card__title { flex: 1 1 auto; min-width: 0; color: var(--lx-text); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.issue-card__concl { flex: 0 0 100%; color: var(--lx-text-mute); font-size: 10.5px; overflow-wrap: anywhere; } +.issue-card--blocked { border-left-color: var(--lx-error); } +.issue-card--deferred { border-left-color: var(--lx-border-strong); } +.issue-card--open { border-left-color: var(--lx-accent); } +.issue-card--done { border-left-color: var(--lx-ok); } +.issue-card--resolved { border-left-color: var(--lx-ok); } +.issue-card--done .issue-card__title, .issue-card--resolved .issue-card__title { color: var(--lx-text-mute); } +.issue-card--resolved .issue-card__num::after { content: ' ✓'; color: var(--lx-ok); } + +@keyframes activity-pulse { 50% { opacity: .35; transform: scale(.78); } } + +/* "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); } + +/* 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--notify { background: var(--lx-bg); border-style: dashed; } +.gate-card--notify .gate-card__label { color: var(--lx-text-mute); } +.gate-card__notify-num { flex: 0 0 auto; font: 600 11px/1.4 var(--lx-mono); color: var(--lx-text-mute); } +.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; } +.gate-card__note { margin-top: 4px; color: var(--lx-text-soft); font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow-y: auto; } +.gate-card__link { color: var(--lx-accent); text-decoration: none; } +.gate-card__link:hover { text-decoration: underline; } +.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: flex-start; gap: 9px; padding: 8px 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); margin-top: 2px; } +.intake-issue__num { flex: 0 0 auto; color: var(--lx-text-mute); font: 11px/1.4 var(--lx-mono); margin-top: 1px; } +.intake-issue__body { min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.intake-issue__title { color: var(--lx-text); font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; } +.intake-issue__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; } +.issue-label { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 9px; border: 1px solid var(--lx-border-strong); background: var(--lx-surface-2); color: var(--lx-text-soft); font: 10px/1.4 var(--lx-mono); } +.intake-issue__comments, .intake-issue__age { color: var(--lx-text-mute); font: 10.5px/1.4 var(--lx-mono); } + +.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: 56px minmax(0, 1fr); gap: 8px; overflow-wrap: anywhere; } +.activity-stream__time { color: var(--lx-text-mute); white-space: nowrap; } +.activity-stream__line--err { color: var(--lx-error); } +/* Tool calls: ordinary commands stay muted; key actions (clone/commit/publish/ + test/install) get an accent tint + a ▸ marker so the main line of work stands + out from the noise. */ +.activity-stream__line--tool .activity-stream__text { color: var(--lx-text-mute); } +.activity-stream__line--key .activity-stream__text { color: color-mix(in srgb, var(--lx-accent) 78%, var(--lx-text-soft)); font-weight: 600; } +/* 本轮结论:一句话收尾,弱强调,和普通状态行区分。 */ +.activity-stream__line--conclusion .activity-stream__text { color: var(--lx-accent); font-weight: 600; } +.activity-stream__keymark { color: var(--lx-accent); } +/* Waiting status line: spinner + text, dimmed. */ +.activity-stream__text--waiting { display: inline-flex; align-items: center; gap: 6px; color: var(--lx-text-mute); } +/* 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 { + display: flex; align-items: center; gap: 6px; list-style: none; + cursor: pointer; color: var(--lx-accent); user-select: none; +} +.activity-prompt summary::-webkit-details-marker { display: none; } +.activity-prompt summary::before { + content: '▸'; flex: 0 0 auto; width: 10px; text-align: center; + color: var(--lx-text-mute); font: 600 10px/1 var(--lx-mono); + transition: transform 120ms ease; +} +.activity-prompt[open] > summary::before { transform: rotate(90deg); } +.activity-prompt summary:hover { text-decoration: underline; } +.activity-prompt--think summary { color: var(--lx-text-mute); } +.activity-prompt__think-label { flex: 0 0 auto; color: var(--lx-text-mute); } +.activity-prompt__think-sep { flex: 0 0 auto; color: var(--lx-border-strong); } +.activity-prompt__think-preview { + flex: 1; min-width: 0; color: var(--lx-text-soft); font: 11px/1.45 var(--lx-mono); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.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; } +.dlg__note--ok { color: var(--lx-ok); border: 1px solid color-mix(in srgb, var(--lx-ok) 45%, transparent); background: color-mix(in srgb, var(--lx-ok) 8%, transparent); border-radius: 6px; padding: 6px 8px; } +.intake-issue__img { flex: 0 0 auto; font-size: 12px; } +.intake-issue__resolved { display: inline-flex; align-items: center; padding: 0 6px; border-radius: 9px; border: 1px solid color-mix(in srgb, var(--lx-ok) 55%, var(--lx-border)); background: color-mix(in srgb, var(--lx-ok) 10%, transparent); color: var(--lx-ok); font: 10px/1.4 var(--lx-mono); } +.intake-issue--resolved .intake-issue__title { color: var(--lx-text-mute); text-decoration: line-through; } +.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. The body is the ONE scroll container; the + stream stays content-height (flex: 0 0 auto) so its scrollHeight never + diverges from clientHeight — a flex:1 stream with the stepper/banner + siblings above used to shrink and become a phantom inner scroll region, + which made the visible scrollbar disagree with the content. */ +.detail-panel__body { display: flex; flex-direction: column; } +/* 决策工作台:需决策/知会 目标右侧面板顶部的审批上下文块。 */ +.decision-workspace { + flex: 0 0 auto; display: flex; flex-direction: column; gap: 10px; + padding: 12px 14px; border-bottom: 1px solid var(--lx-border); + background: var(--lx-elevated); +} +.decision-workspace__head { + color: var(--lx-text-mute); font-size: 10px; font-weight: 700; + letter-spacing: .05em; text-transform: uppercase; +} +/* Issues 管理:进行中/排队/暂停/异常 目标右侧面板顶部的 issue 勾选增删块。 */ +.detail-issues { + flex: 0 0 auto; display: flex; flex-direction: column; gap: 8px; + padding: 12px 14px; border-bottom: 1px solid var(--lx-border); + background: var(--lx-elevated); +} +.activity-stream--panel { flex: 0 0 auto; 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; } + .status-bar__seg--pulse, .review-zone--unread .zone-head, .stepper__step--active .stepper__marker { animation: none !important; } +} + +/* ══ #10 全局状态条 ───────────────────────────────────────── */ +.status-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 14px 0; padding: 0 2px; } +.status-bar__seg { + display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; + border: 1px solid var(--lx-border); border-radius: 12px; background: transparent; + color: var(--lx-text-mute); font-size: 11px; cursor: pointer; +} +.status-bar__seg:hover { border-color: var(--lx-border-strong); color: var(--lx-text); } +.status-bar__seg b { color: var(--lx-text-soft); font: 600 11px/1 var(--lx-mono); } +/* Tab accent colors per group. */ +.status-bar__seg--active { color: var(--lx-ok); border-color: color-mix(in srgb, var(--lx-ok) 34%, var(--lx-border)); } +.status-bar__seg--decisions { color: var(--lx-review); border-color: color-mix(in srgb, var(--lx-review) 40%, var(--lx-border)); } +.status-bar__seg--error { color: var(--lx-error); border-color: color-mix(in srgb, var(--lx-error) 34%, var(--lx-border)); } +.status-bar__seg--pulse { animation: status-pulse 1.4s ease-in-out infinite; } +/* The active tab reads as a pressed/selected chip. */ +.status-bar__seg.is-active { background: var(--lx-hover); border-color: var(--lx-border-strong); color: var(--lx-text); } +.status-bar__seg.is-active b { color: var(--lx-text); } +@keyframes status-pulse { 50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--lx-review) 25%, transparent); } } + +/* review 未读脉冲 + 角标 */ +.review-zone--unread .zone-head { animation: review-pulse 1.2s ease-in-out infinite; } +@keyframes review-pulse { 50% { background: color-mix(in srgb, var(--lx-review) 7%, transparent); } } +.review-zone__badge { + margin-left: auto; padding: 0 6px; border-radius: 9px; background: var(--lx-review); + color: #161616; font: 700 10px/1.6 var(--lx-sans); +} + +/* detail header:动作行 + 内联取消 */ +.detail-panel__head-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.detail-panel__action { + margin-top: 5px; color: var(--lx-accent); font: 12px/1.4 var(--lx-mono); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 440px; +} + +/* ══ #4 按 turn 分组日志 ──────────────────────────────────── */ +.activity-turn { border: 0; margin: 0; } +.activity-turn > summary { + display: flex; align-items: center; gap: 8px; list-style: none; cursor: pointer; + padding: 5px 8px; margin: 4px 0 2px; border: 1px solid var(--lx-border); + border-radius: 6px; background: var(--lx-surface-2); color: var(--lx-text-soft); + font: 600 11px/1.4 var(--lx-mono); user-select: none; +} +.activity-turn > summary::-webkit-details-marker { display: none; } +.activity-turn > summary .activity-turn__caret { transition: transform 120ms ease; color: var(--lx-text-mute); } +.activity-turn[open] > summary .activity-turn__caret { transform: rotate(90deg); } +.activity-turn > summary .activity-turn__count { margin-left: auto; color: var(--lx-text-mute); font-weight: 400; } +.activity-turn__body { display: flex; flex-direction: column; gap: 6px; padding: 4px 0 6px 6px; border-left: 1px solid var(--lx-border); margin-left: 8px; } + +/* ══ #1 diff-first 审批 ───────────────────────────────────── */ +.approve-diff { display: flex; flex-direction: column; gap: 6px; padding: 8px 9px; border: 1px solid var(--lx-border); border-radius: var(--lx-radius); background: var(--lx-bg); } +.approve-diff__head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.approve-diff__count { color: var(--lx-text); font-size: 11.5px; font-weight: 600; } +.approve-diff__stat { color: var(--lx-ok); font: 11px/1.4 var(--lx-mono); } +.approve-diff__list { display: flex; flex-direction: column; max-height: 180px; overflow: auto; border-top: 1px solid var(--lx-border); } +.approve-diff__file { display: flex; align-items: center; gap: 8px; padding: 3px 2px; font: 11px/1.5 var(--lx-mono); border-bottom: 1px solid var(--lx-border); } +.approve-diff__file:last-child { border-bottom: 0; } +.approve-diff__file-path { flex: 1; min-width: 0; color: var(--lx-text-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.approve-diff__file-num { flex: 0 0 auto; color: var(--lx-text-mute); } +.approve-diff__file-num--add { color: var(--lx-ok); } +.approve-diff__file-num--del { color: var(--lx-error); } +.approve-diff__hunk summary { cursor: pointer; color: var(--lx-accent); font-size: 11px; } +.approve-diff__hunk summary:hover { text-decoration: underline; } +.approve-diff__loading { color: var(--lx-text-mute); font-size: 11px; font-style: italic; } + +/* ══ #2 阶段流水线 stepper ────────────────────────────────── */ +.detail-panel__stepper { flex: 0 0 auto; display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 0; } +/* 横向内联 stepper:放进标题行右侧,不占 body 高度。 */ +.detail-panel__title-row { display: flex; align-items: baseline; gap: 10px; min-width: 0; } +.detail-panel__stepper-inline { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; } +.stepper-inline { display: inline-flex; align-items: baseline; gap: 5px; font-size: 11px; } +.stepper-inline__sep { color: var(--lx-text-mute); } +.stepper-inline__step { color: var(--lx-text-mute); } +.stepper-inline__step--done { color: var(--lx-ok); } +.stepper-inline__step--active { color: var(--lx-accent); font-weight: 600; } +.stepper__step { display: flex; align-items: center; gap: 8px; padding: 3px 0; } +.stepper__marker { flex: 0 0 auto; display: grid; place-items: center; width: 15px; height: 15px; border-radius: 50%; border: 1px solid var(--lx-border-strong); color: var(--lx-text-mute); font: 700 9px/1 var(--lx-mono); } +.stepper__step--done .stepper__marker { border-color: var(--lx-ok); color: var(--lx-ok); background: color-mix(in srgb, var(--lx-ok) 10%, transparent); } +.stepper__step--active .stepper__marker { border-color: var(--lx-accent); color: var(--lx-accent); animation: activity-pulse 1.4s ease-in-out infinite; } +.stepper__step--error .stepper__marker { border-color: var(--lx-error); color: var(--lx-error); } +.stepper__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--lx-text-soft); font-size: 11.5px; } +.stepper__step--active .stepper__label { color: var(--lx-text); font-weight: 600; } +.stepper__step--done .stepper__label { color: var(--lx-text-mute); } +.stepper__sub { margin-left: 23px; color: var(--lx-text-mute); font-size: 10.5px; } + +/* ══ #5 空状态 / 上手引导 ─────────────────────────────────── */ +.onboard { display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: 460px; text-align: center; } +.onboard__title { margin: 6px 0 0; font-size: 14px; } +.onboard__hint { margin: 0; color: var(--lx-text-mute); font-size: 12px; } +.onboard__samples { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } +.onboard__browse { display: flex; gap: 6px; align-items: center; } +.onboard__browse input { + min-height: 30px; width: 220px; padding: 5px 9px; border: 1px solid var(--lx-border-strong); + border-radius: 6px; background: var(--lx-surface-2); color: var(--lx-text); font: 12px/1.3 var(--lx-mono); +} +.onboard__browse input:focus-visible { outline: 2px solid var(--lx-accent); outline-offset: 1px; } + +/* ══ #8 错误恢复横幅 ──────────────────────────────────────── */ +.goal__error-banner { + flex: 0 0 auto; display: flex; flex-direction: column; gap: 6px; margin-top: 7px; padding: 7px 8px; + border: 1px solid color-mix(in srgb, var(--lx-error) 45%, var(--lx-border)); + border-radius: var(--lx-radius); background: color-mix(in srgb, var(--lx-error) 7%, transparent); +} +.goal__error-text { color: var(--lx-error); font: 10.5px/1.45 var(--lx-mono); word-break: break-all; } +.goal__error-actions { display: flex; gap: 6px; } + +/* ══ 模型未响应行动卡 ──────────────────────────────────────── */ +.goal__model-hang { + flex: 0 0 auto; display: flex; flex-direction: column; gap: 6px; margin-top: 7px; padding: 7px 8px; + border: 1px solid color-mix(in srgb, var(--lx-warn) 45%, var(--lx-border)); + border-radius: var(--lx-radius); background: color-mix(in srgb, var(--lx-warn) 7%, transparent); +} +.goal__model-hang-text { color: var(--lx-warn); font: 10.5px/1.45 var(--lx-mono); word-break: break-all; } +.goal__model-hang-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.goal__model-hang-select { + flex: 1 1 140px; min-width: 0; max-width: 240px; height: 22px; padding: 0 6px; + border: 1px solid var(--lx-border-strong); border-radius: 5px; + background: var(--lx-bg); color: var(--lx-text); font: 11px/1 var(--lx-mono); +} + +/* ══ #9 模型能力标签 / 视觉提示 ───────────────────────────── */ +.composer__vision-hint { width: min(960px, 100%); margin: 0 auto 6px; padding: 0 4px; color: var(--lx-warn); font-size: 11px; } +.model-tag { color: var(--lx-text-mute); font-size: 10px; } +.model-tag--vision { color: var(--lx-ok); } +.model-tag--novision { color: var(--lx-warn); } + +/* ══ #10 排队中(backlog 可见) ───────────────────────────── */ +.run-rail__queued { 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--queued { opacity: .68; } +.run-item--queued:hover { opacity: 1; } + +/* ══ issue 清单样式(已接入右侧面板的 Issues 区块)════════ */ +.goal-picker__checklist { display: flex; flex-direction: column; gap: 2px; } +.goal-picker__checkrow { display: flex; align-items: center; gap: 7px; padding: 3px 6px; border-radius: 6px; cursor: pointer; } +.goal-picker__checkrow:hover { background: var(--lx-hover); } +.goal-picker__checkrow input[type="checkbox"] { flex: 0 0 auto; width: 13px; height: 13px; accent-color: var(--lx-accent); margin: 0; } +.goal-picker__available { display: flex; flex-direction: column; gap: 2px; } +.goal-picker__apply { align-self: stretch; margin-top: 2px; } +.goal-picker__detail-head { display: flex; align-items: center; gap: 8px; } +.goal-picker__detail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--lx-text); font-weight: 600; font-size: 12.5px; } +.goal-picker__section-head { color: var(--lx-text-mute); font-size: 10px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } +.goal-picker__detail-summary { color: var(--lx-text-mute); font-size: 11px; padding-bottom: 4px; border-bottom: 1px dashed var(--lx-border); } +.goal-picker__empty { color: var(--lx-text-mute); font-size: 11px; } + +/* ══ 视觉工艺打磨(只动样式,不动 DOM / 行为)─────────────── + Anti-slop pass: one easing hand, press feedback, tabular + numerals for every run/count/timing readout, and keyboard + focus parity across every interactive surface. */ + +/* Crisper text rendering. */ +body { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } + +/* Every count / id / timer / stat uses tabular figures so the + readouts stop jittering as the clock ticks. */ +.goal__id, .run-item__id, .other-tasks__id, .zone-count, .intake-count, +.countdown, .detail-panel__status-clock, .approve-diff__stat, .approve-diff__count, +.approve-diff__file-num, .issue-chip, .goal-status, .activity-stream__time, +.intake-issue__num, .intake-issue__comments, .intake-issue__age, +.stepper__marker, .goal__gate-badge, .review-zone__badge, .model-tag, +.composer__kind, .board-more__chip b, .status-bar__seg b { + font-variant-numeric: tabular-nums; +} + +/* One easing hand for hover/press across interactive surfaces. */ +.btn, .goal, .run-item, .issue-chip, .board-more__chip, .composer__chip, +.composer-target__trigger, .composer-target__item, .goal-picker__item, +.goal-picker__item-main, .goal-picker__issue-btn, .log-bottom, +.composer__model, .composer__target, .composer__submit { + transition: border-color var(--lx-dur-fast) var(--lx-ease), + background var(--lx-dur-fast) var(--lx-ease), + color var(--lx-dur-fast) var(--lx-ease), + transform var(--lx-dur-fast) var(--lx-ease), + box-shadow var(--lx-dur-fast) var(--lx-ease), + opacity var(--lx-dur-fast) var(--lx-ease); +} + +/* Press feedback — a hair of travel so the click registers. */ +.btn:active:not(:disabled), .composer__chip:active, +.board-more__chip:active, .composer-target__item:active, +.goal-picker__issue-btn:active, .goal-picker__item-main:active { + transform: translateY(1px); +} +.composer__submit:active:not(:disabled) { transform: scale(.96); } + +/* The composer is the primary surface: a soft accent ring on focus. */ +.composer__box:focus-within { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--lx-accent) 26%, transparent), + 0 2px 8px color-mix(in srgb, var(--lx-text) 5%, transparent); +} + +/* Keyboard parity: the same 2px accent ring on every focusable surface + that previously only got it on buttons/cards. The model/target chips + light their border when their inner select/trigger is focused. */ +.run-item:focus-visible, .issue-chip:focus-visible, +.composer-target__trigger:focus-visible, .composer-target__item:focus-visible, +.goal-picker__trigger:focus-visible, .goal-picker__item-main:focus-visible, +.goal-picker__issue-btn:focus-visible, .goal-picker__item:focus-visible, +.intake-issue:focus-visible, .activity-turn > summary:focus-visible, +.composer__chip:focus-visible, .log-bottom:focus-visible { + outline: 2px solid var(--lx-accent); outline-offset: 1px; +} +.composer__model:focus-within, .composer__target:focus-within { + border-color: color-mix(in srgb, var(--lx-accent) 68%, var(--lx-border)); +} + +/* ══ 待审批 gate 卡 · 注意力引导 ──────────────────────────── + 等待人工的动作必须一眼可见:左侧决策色轨 + 琥珀 kicker + + 状态点轻微呼吸(reduced-motion 下静止)。 */ +.gate-card--block { + border-left: 3px solid var(--lx-review); + background: color-mix(in srgb, var(--lx-review) 6%, var(--lx-surface)); +} +.gate-card--block .gate-card__kicker { color: var(--lx-review); } +.gate-card--block .gate-card__label::before { + animation: gate-pulse 1.6s ease-in-out infinite; +} +@keyframes gate-pulse { + 0%, 100% { box-shadow: 0 0 0 0 transparent; } + 50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--lx-review) 32%, transparent); } +} + +/* ══ 日志流 · 排版层级 ───────────────────────────────────── + agent 的原话是主角:更松的行距 + pretty 换行避免孤字; + 时间列与 turn 头收紧字距,系统噪声退后。 */ +.activity-stream__time { letter-spacing: .02em; } +.activity-stream__line--agent .activity-stream__text { + font-size: 12.5px; line-height: 1.65; text-wrap: pretty; +} +.activity-turn > summary { letter-spacing: .02em; } + +/* ══ 审批 diff · 数值可读性 ─────────────────────────────── */ +.approve-diff__count, .approve-diff__stat { letter-spacing: .01em; } + +/* reduced-motion:待审批状态点静止。 */ +@media (prefers-reduced-motion: reduce) { + .gate-card--block .gate-card__label::before { animation: none; } +} + +/* loading spinner */ +.loading { display: inline-flex; align-items: center; gap: 6px; color: var(--lx-text-mute); font-size: 11px; } +.spinner { flex: 0 0 auto; display: inline-block; width: 12px; height: 12px; border: 2px solid var(--lx-border); border-top-color: var(--lx-accent); border-radius: 50%; animation: lx-spin 0.8s linear infinite; } +@keyframes lx-spin { to { transform: rotate(360deg); } } +/* Resolved waiting line: spinner → done checkmark (✓) or failure cross (✗). */ +.status-icon--done { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 12px; height: 12px; color: var(--lx-ok); font-weight: 700; font-size: 11px; line-height: 1; } +.status-icon--fail { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 12px; height: 12px; color: var(--lx-error); font-weight: 700; font-size: 11px; line-height: 1; } +@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } } + +/* ══ 环境引导页(Setup Gate)─────────────────────────────── */ +.env-onboard { + position: fixed; inset: 0; z-index: 60; + display: flex; align-items: center; justify-content: center; + padding: 20px; background: color-mix(in srgb, var(--lx-bg, #16171c) 88%, transparent); + backdrop-filter: blur(6px); + animation: env-fade-in .28s ease; +} +.env-onboard[hidden] { display: none; } +.env-onboard__card { + width: min(520px, 100%); max-height: 86vh; overflow-y: auto; + padding: 26px 28px; border: 1px solid var(--lx-border); border-radius: 14px; + background: var(--lx-surface); box-shadow: 0 18px 60px color-mix(in srgb, var(--lx-text) 22%, transparent); +} +.env-onboard__state { display: flex; flex-direction: column; gap: 14px; } +.env-onboard__state[hidden] { display: none; } +.env-onboard h2 { margin: 0; font: 650 17px/1.35 var(--lx-sans); } +.env-onboard p { margin: 0; color: var(--lx-text-mute); font-size: 12.5px; line-height: 1.55; } +#env-state-loading { flex-direction: row; align-items: center; justify-content: center; color: var(--lx-text-mute); padding: 10px 0; } +.env-onboard__list { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; } +.env-onboard__item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--lx-border); border-radius: 9px; font-size: 12.5px; } +.env-onboard__item--ok { border-color: color-mix(in srgb, var(--lx-ok) 40%, var(--lx-border)); } +.env-onboard__item--miss { border-color: color-mix(in srgb, var(--lx-review) 55%, var(--lx-border)); } +.env-onboard__item-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; } +.env-onboard__item--ok .env-onboard__item-dot { background: var(--lx-ok); } +.env-onboard__item--miss .env-onboard__item-dot { background: var(--lx-review); } +.env-onboard__item-label { flex: 1; } +.env-onboard__item-why { color: var(--lx-text-mute); font-size: 11px; } +.env-onboard__item-cmd { flex: 0 0 auto; max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 7px; border: 1px solid var(--lx-border); border-radius: 6px; background: color-mix(in srgb, var(--lx-text) 5%, transparent); color: var(--lx-text-mute); font: 500 10.5px/1.5 var(--lx-mono, monospace); user-select: all; } +.env-onboard__actions { display: flex; gap: 8px; flex-wrap: wrap; } +.env-onboard__config { border: 1px solid var(--lx-border); border-radius: 9px; padding: 4px 12px 10px; } +.env-onboard__config summary { cursor: pointer; font-size: 12px; color: var(--lx-accent); padding: 8px 0; user-select: none; list-style: none; } +.env-onboard__config summary::before { content: '▸ '; } +.env-onboard__config[open] summary::before { content: '▾ '; } +.env-onboard__config-body { display: flex; flex-direction: column; gap: 8px; } +.env-onboard__field { display: grid; grid-template-columns: 92px 1fr; align-items: center; gap: 8px; font-size: 11.5px; color: var(--lx-text-mute); } +.env-onboard__field input { width: 100%; padding: 6px 8px; border: 1px solid var(--lx-border); border-radius: 7px; background: var(--lx-elevated); color: var(--lx-text); font-size: 12px; } +.env-onboard__log { margin: 0; padding: 10px 12px; max-height: 260px; overflow-y: auto; border: 1px solid var(--lx-border); border-radius: 9px; background: var(--lx-elevated); color: var(--lx-text-mute); font: 11px/1.6 var(--lx-mono, monospace); white-space: pre-wrap; word-break: break-all; } +.env-onboard__done-mark { width: 44px; height: 44px; margin: 0 auto; display: grid; place-items: center; border-radius: 50%; background: color-mix(in srgb, var(--lx-ok) 16%, transparent); color: var(--lx-ok); font-size: 22px; font-weight: 700; } +@keyframes env-fade-in { from { opacity: 0; } to { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { + .env-onboard { animation: none; backdrop-filter: none; } +} \ No newline at end of file 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..c940c83137 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js @@ -0,0 +1,7934 @@ +// 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: '待处理', + issueResolved: '已解决', + issueResolvedExternally: (n) => `以下 issue 已被维护者解决,无需再修复:${n}`, + issueResolvedExternallyShort: '已解决(外部)', + issueComments: (n) => `💬 ${n}`, + issueAgeNow: '刚刚', + issueAgeMin: (n) => `${n} 分钟前`, + issueAgeHour: (n) => `${n} 小时前`, + issueAgeDay: (n) => `${n} 天前`, + issueAgeMonth: (n) => `${n} 个月前`, + moreIssues: (n) => `+${n}`, + moreIssuesHint: (n) => `查看全部 issue(还有 ${n} 个)`, + resumeNothingToDo: '该目标下已没有可继续推进的 issue(全部已完成或已被维护者解决)。', + installLoopxBtn: 'pip 安装 loopx', + envCheckLoading: '正在检查环境…', + envOnboardTitle: '首次使用 · 配置环境', + envOnboardHint: '自动修复 issue 需要下面几项依赖,一键装好后即可开始。', + envConfigSummary: '高级配置(模型,默认已填好)', + envVlmModel: 'VLM 模型', + envVlmBase: 'VLM 地址', + envVlmKey: 'VLM API Key', + envInstallBtn: '一键安装并配置', + envProgressTitle: '正在安装…', + envDoneTitle: '环境就绪', + envDoneHint: '可以开始自动修复 issue 了。', + envDoneBtn: '开始使用', + envItemPython: 'Python', + envItemGit: 'Git', + envItemLoopx: 'loopx', + envItemOpenViking: 'OpenViking', + envItemOpenVikingServer: 'OpenViking 服务', + envItemOvCli: 'ov CLI 连接', + envItemGh: 'GitHub 登录', + envInstallLinkBtn: '打开下载页', + envInstallCmdHint: '或复制此命令在终端安装(装好后点「重新检测」)', + envOvCliReconnect: '重连', + envVerifying: '正在复检环境…', + envVerifyOk: '复检通过,环境就绪', + envVerifyFailed: '复检未全部通过,请检查下方清单', + envVlmKeyEmpty: 'VLM API Key 为空:图像理解相关能力(含 OpenViking VLM 调用)将不可用,可稍后在高级配置中补填', + bootPausedBanner: (n) => `上次有 ${n} 个任务在运行(重启后已暂停)`, + bootPausedResumeAll: '全部继续', + bootPausedDismiss: '忽略', + bootPausedResumed: (n) => `已继续 ${n} 个任务`, + diffBranchLabel: (b) => `分支:${b}`, + installingLoopx: '正在安装…(可能需要几分钟)', + installDone: '安装完成', + installFailed: '安装失败', + runOnce: '执行一次', + resumeTask: '继续任务', + resumeTaskHint: '继续该任务:恢复心跳监控与自动执行', + runShort: '运行', + stopShort: '停止', + stopTask: '中止任务', + stopTaskHint: '中止整个任务:取消本次运行、关闭心跳监控与自动执行(任务保留,可继续)', + deleteTask: '删除任务', + deleteTaskHint: '删除整个任务(该任务下所有 issue 一起删):归档运行记录并移除注册表条目(注册表会先备份)。单个 issue 请在 issue 行点「移除」', + 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}」:归档运行记录、移除注册表条目(注册表会先备份)。该任务下的所有 issue 会一并删除,看板不再显示该任务。只想删单个 issue?请在 issue 行点「移除」。`, + 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: '自动已关', + statusAutoTripped: '连续失败已暂停(悬停看原因)', + statusAuto: '自动运行', + statusGated: '等待审批', + statusCooldown: (n) => `冷却中 · ${n} 分钟后重试`, + statusExternalResolved: (n) => `${n} 项已被维护者解决`, + stageGated: '阶段:等待你的确认', + stageRunning: '阶段:执行中', + stageFixing: (n) => `阶段:修复 #${n}`, + stagePlanning: '阶段:规划中', + skippedDuplicates: (n) => `(已跳过 ${n} 个重复 Issue)`, + skippedResolved: (n) => `(已跳过 ${n} 个已解决的 Issue)`, + issueResolvedLog: (num, reason) => `Issue #${num} 已被解决(${reason}),跳过修复`, + issueResolvedStopped: (num, reason) => `Issue #${num} 已被维护者解决(${reason}),已自动跳过后续修复`, + rewardMemoryOn: 'reward memory 已启用(issue-fix 补丁规划面)', + rewardMemoryOff: (e) => `reward memory 未启用:${e}`, + repoMemorySyncOn: (scope) => `仓库记忆已导入 OpenViking(${scope})`, + repoMemorySyncOff: (e) => `仓库记忆导入跳过:${e}`, + repoMemorySyncStarted: '仓库记忆后台索引中(OpenViking),不影响修复进行', + memoryIndexedOk: '仓库记忆已索引(OpenViking)', + memoryIndexedMissing: '仓库记忆未索引:同步可能超时/失败,Agent 暂无可检索的仓库上下文', + memoryServerDown: 'OpenViking 服务离线,仓库记忆不可用', + runCancelled: '运行已取消', + runCancelledByHost: '本轮被宿主取消(非手动停止),将按失败退避自动重试', + turnStalled: (m) => `运行僵死:约 ${m} 分钟没有收到 Agent 事件(可能宿主已静默取消本轮),已取消该回合并允许自动重试。`, + turnModelHang: (m) => `模型 API 未响应:约 ${m} 分钟没有返回第一个字(可能模型服务超时/过载),已取消该回合并允许自动重试。`, + modelHangTitle: '模型未响应 — 需要你决定', + modelHangText: (m) => `当前模型约 ${m} 分钟没有返回第一个字(可能限流/过载)。切换模型立即重试,或继续等待自动重试。`, + modelHangSwitch: '切换模型', + modelHangSwitchApply: '切换并重试', + modelHangKeepWaiting: '继续等待', + modelHangSwitched: (m) => `已切换为 ${m},立即重试`, + modelHangKeptWaiting: '继续使用当前模型,等待自动重试', + notifModelHangTitle: '模型未响应', + notifModelHangBody: (id, m) => `${id}:模型约 ${m} 分钟未响应,可切换模型或继续等待`, + streamTrimmed: (n) => `(前文已折叠 ${n} 字符)`, + groupBacklog: '排队中', + groupActive: '进行中', + groupReview: '等你处理', + colSubReview: '阻塞 · 需要你批准后继续', + colSubActive: 'Agent 正在执行', + groupDecisions: '需决策', + groupNotices: '知会', + colSubDecisions: '阻塞 · 需要你批准后继续', + colSubNotices: '受阻或暂不修复 · 仅知会', + detailEmptyHint: '在上方选择进行中的目标查看日志;或粘贴 Issue 链接创建新任务', + goalPickerLabel: (n) => `进行中的目标 (${n})`, + goalPickerIssues: '正在修复', + goalPickerIssuesHint: '查看正在修复的 Issues', + goalPickerNoIssues: '暂无 issue 信息', + goalPickerOpenLog: '打开日志', + goalPickerActions: '操作', + issueSummary: (total, done) => `${total} 个 issue · ${done} 完成`, + issuePause: '暂停', + issueResume: '恢复', + issueRemove: '移除', + issueReopen: '重开', + issueAdd: '新增 issue', + issueAddPlaceholder: 'Issue 链接或 #编号', + issueAddBtn: '追加', + issueSelected: '计划中修复的 Issue', + issueAvailable: '仓库其它 Issue', + issueLoading: '正在读取 Issues…', + issueApply: '应用更改', + issueApplyCount: (n) => `应用更改 (${n})`, + issueNoMore: '没有其它 open issues', + groupDone: '已完成', + taskPlaceholder: '粘贴 GitHub Issue / 仓库 / Issues 列表链接,可附加修复要求', + taskGuidePlaceholder: (name) => `正在向「${name}」插话:输入指令引导 Agent 继续(不创建新任务)`, + taskLinkPlaceholder: '粘贴 GitHub Issue / 仓库 / Issues 列表链接', + taskNotePlaceholder: '附加修复要求(可选)', + taskGuideEmpty: '输入指令引导 Agent 继续(先在上方选择要引导的任务)', + taskNeedLink: '请粘贴 GitHub Issue / 仓库 / Issues 列表链接', + taskSend: '发送', + composerModeNew: '新建修复任务', + composerModeGuide: '引导运行中任务', + 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: '正在刷新状态…', + stageWriteTodos: '写入修复 todos', + stageResolved: '跳过已解决的 issue…', + 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} 项`, + notifyCount: '知会', + decisionCount: (n) => `${n} 待决策`, + noticeCountN: (n) => `${n} 知会`, + noticeAck: '已读', + notifyGroup: '知会', + 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: '结论:任务已结束', + viewOriginal: '查看原文', + approveGate: '批准', + completeTodoBtn: '标记完成', + approveGateTitle: '批准这项操作?', + todoDoneTitle: '标记为已完成?', + approveTitle: '确认这项操作?', + approveNote: '备注(可选,写入 todo 完成记录)', + approveConfirm: '批准并继续', + todoDoneConfirm: '标记完成', + approveGateHint: '这是需要你批准的事项:批准后,任务将按该事项继续执行。', + todoDoneHint: '这是知会/指示类事项:标记完成即可,不需要批准,也不会触发新操作。', + approveDone: '已批准,任务将继续推进', + approveResumed: '批准后任务已恢复自动执行', + rejectGate: '拒绝', + rejectNote: '用户已拒绝该项改动', + rejectDone: '已拒绝该项改动,任务将重新规划', + 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}`, + ghLoginWorking: '登录中…', + ghLoginDoneShort: '已登录', + 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: '需要你决定', + sectionNotify: '知会(无需操作)', + notifyAck: '标记已读', + notifyAckAll: '全部标记已读', + notifyAckHint: '仅消除提醒,不改变 issue 状态', + notifyResumeHint: '把该 issue 放回执行队列,让 Agent 继续修', + notifyNoteLabel: 'Agent 说明(做了什么 + 结论)', + notifyMore: (n) => `还有 ${n} 个知会(点击展开)`, + notifyGroupResolved: '已解决(无需处理)', + notifyGroupBlocked: '无法继续(受阻)', + notifyGroupDeferred: '暂不修复', + 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…(新会话,从 loopx 注册表续跑)', + activityResumeSession: '恢复之前的会话上下文,继续执行…', + resumeReconcileTitle: '续跑对账(loopx 注册表,不复用会话)', + cancelTurnLogged: '已请求取消本轮执行', + activityWaitingModel: '等待模型响应…', + activityWaitingModelCtx: (ctx) => `等待模型响应…(上下文约 ${ctx})`, + activityWaitingModelCtxEta: (ctx, eta) => `等待模型响应…(上下文约 ${ctx},上次约 ${eta})`, + activityStartingDone: 'Agent 已启动', + activityResumeDone: '会话上下文已恢复', + activityModelResponded: '模型已响应', + activityModelRespondedEta: (eta) => `模型已响应(${eta})`, + toolShell: '执行命令', + toolRead: '读取', + toolSearch: '搜索', + toolWrite: '写入', + toolEdit: '编辑', + toolCode: '运行代码', + toolCall: '工具', + activityDiving: 'Bitfun努力解bug中...', + activityStalled: '可能卡住了(无输出)', + activityModelHang: '模型未响应(等待返回)', + activityToolRunning: (name, dur) => `工具运行中 · ${name} · 已 ${dur}`, + idleFor: (t) => `距上次输出 ${t}`, + durationMinutes: (m, s) => `${m}分${s}秒`, + durationSeconds: (s) => `${s}秒`, + activitySentPrompt: (n) => `已向 Agent 发送指令(${n} 字符,点击展开)`, + activityRunning: (elapsed) => `Agent 正在执行 · 已用时 ${elapsed}`, + activityCommitted: 'bitfun-loopx 已提交本次执行结果', + activityValidationPassed: '独立校验已通过', + activityValidationFailed: '独立校验未通过', + activityStateUpdated: '目标状态已更新', + activityCompleted: '执行已完成', + activityCompletedValidated: '执行已完成 · 校验通过', + activityFailed: '执行失败', + turnConclusionLabel: '本轮结论:', + feedbackAsk: '对最近这轮满意吗?', + feedbackGood: '有用', + feedbackBad: '没用', + feedbackDone: (r) => (r === 'positive' ? '已记录:有用' : '已记录:没用'), + feedbackError: (e) => `反馈记录失败:${e}`, + 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 描述包含图片(截图),文字可能不足以定位问题', + issueResolvedBadge: '已解决', + issueResolvedBadgeTitle: (reason) => `该 issue 上游已关闭(${reason || 'closed'}),创建任务时会自动跳过、不再修复`, + intakeResolvedWarn: (n) => `✓ 检测到 ${n} 个 issue 上游已解决:创建任务时会自动跳过这些 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} 个,列表未取全)`, + diffFilesCount: (n) => `${n} 个改动文件`, + diffStatLabel: (a, d) => `+${a} −${d}`, + diffLoading: '正在加载代码改动…', + diffEmpty: '尚未检测到代码改动(分支可能尚未提交,或与主分支无差异)', + diffViewHunk: '查看完整差异', + diffTruncated: '(diff 已裁剪,仅显示前 40000 字符)', + stepperPlan: '规划修复方案', + stepperFix: '修复 Issue', + stepperPublish: '发布 / 提交 PR', + stepperIssuesDone: (done, total) => `Issue ${done}/${total}`, + cancelTurn: '取消本轮', + turnLabel: (n, time) => `第 ${n} 轮 · ${time}`, + turnLines: (n) => `${n} 行`, + emptyBoardTitle: '开始修复你的第一个 Issue', + emptyBoardHint: '粘贴 GitHub Issue / 仓库链接;或输入 owner/repo 浏览该仓库的 open issues。', + emptySampleIssue: '示例:单个 Issue', + emptySampleRepo: '示例:仓库主页', + emptyBrowseBtn: '浏览', + emptyBrowsePlaceholder: 'owner/repo', + kbHint: '快捷键:j/k 移动 · Enter 查看 · a 批准 · x 选中', + errorBannerTitle: '最近一次错误', + errorRetry: '重试', + errorClearState: '清除该任务状态', + modelVisionYes: '视觉 ✓', + modelVisionNo: '视觉 ✗', + composerVisionHint: '当前模型不支持读取截图:若该 Issue 依赖图片,修复质量可能受影响。', + sbRunning: '运行中', + sbNeedsYou: '等你', + sbQueued: '排队中', + sbStopped: '已停', + sbError: '异常', + sbDone: '已完成', + sbArchived: '已归档', + queuedHint: '排队中:等待 loopx 调度下一轮自动执行', + }, + '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: 'fixing', + issueBlocked: 'cannot continue', + issueDeferred: "won't fix now", + issuePending: 'pending', + issueResolved: 'Resolved', + issueResolvedExternally: (n) => `Resolved upstream, no fix needed: ${n}`, + issueResolvedExternallyShort: 'resolved upstream', + issueComments: (n) => `💬 ${n}`, + issueAgeNow: 'just now', + issueAgeMin: (n) => `${n}m ago`, + issueAgeHour: (n) => `${n}h ago`, + issueAgeDay: (n) => `${n}d ago`, + issueAgeMonth: (n) => `${n}mo ago`, + moreIssues: (n) => `+${n}`, + moreIssuesHint: (n) => `View all issues (${n} more)`, + resumeNothingToDo: 'No actionable issues remain in this task (all done or resolved upstream).', + installLoopxBtn: 'pip install loopx', + envCheckLoading: 'Checking environment…', + envOnboardTitle: 'First-time setup', + envOnboardHint: 'Auto-fixing issues needs these dependencies. Install them to get started.', + envConfigSummary: 'Advanced config (model, pre-filled)', + envVlmModel: 'VLM model', + envVlmBase: 'VLM base URL', + envVlmKey: 'VLM API key', + envInstallBtn: 'Install & configure', + envProgressTitle: 'Installing…', + envDoneTitle: 'Environment ready', + envDoneHint: 'You can start auto-fixing issues now.', + envDoneBtn: 'Get started', + envItemPython: 'Python', + envItemGit: 'Git', + envItemLoopx: 'loopx', + envItemOpenViking: 'OpenViking', + envItemOpenVikingServer: 'OpenViking server', + envItemOvCli: 'ov CLI connection', + envItemGh: 'GitHub sign-in', + envInstallLinkBtn: 'Open download page', + envInstallCmdHint: 'Or copy this command into a terminal (then press Re-check)', + envOvCliReconnect: 'Reconnect', + envVerifying: 'Re-checking the environment…', + envVerifyOk: 'Re-check passed, environment ready', + envVerifyFailed: 'Re-check did not fully pass — see the checklist', + envVlmKeyEmpty: 'VLM API key is empty: vision features (incl. OpenViking VLM calls) will be unavailable until you fill it in under advanced config', + bootPausedBanner: (n) => `${n} task${n > 1 ? 's were' : ' was'} running before the restart (now paused)`, + bootPausedResumeAll: 'Resume all', + bootPausedDismiss: 'Dismiss', + bootPausedResumed: (n) => `${n} task${n > 1 ? 's' : ''} resumed`, + diffBranchLabel: (b) => `branch: ${b}`, + 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', + runShort: 'Run', + stopShort: 'Stop', + stopTask: 'Abort task', + stopTaskHint: 'Abort the whole task: cancel the current run, disable heartbeat and auto-run (the task is kept and can be resumed)', + deleteTask: 'Delete task', + deleteTaskHint: 'Delete the whole task (all its issues together): archive its runtime and remove the registry entry (backed up first). To remove one issue, use Remove on that issue row', + 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", is kept, and can be resumed anytime.`, + confirmStop: 'Abort it', + deleteConfirmTitle: 'Delete the whole task?', + deleteConfirmText: (id) => `This deletes the whole task "${id}": its runtime is archived and its registry entry removed (the registry is backed up first). All issues under it are deleted too, and it no longer appears on the board. To remove a single issue, use Remove on that issue row instead.`, + 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', + statusAutoTripped: 'Paused after repeated failures (hover for reason)', + statusAuto: 'Auto-run on', + statusGated: 'Needs approval', + statusCooldown: (n) => `cooldown · retry in ${n}m`, + statusExternalResolved: (n) => `${n} resolved upstream`, + 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)`, + skippedResolved: (n) => ` (${n} already-resolved issue${n > 1 ? 's' : ''} skipped)`, + issueResolvedLog: (num, reason) => `Issue #${num} was already resolved (${reason}) — skipping`, + issueResolvedStopped: (num, reason) => `Issue #${num} was resolved upstream (${reason}) — further fixing skipped automatically`, + rewardMemoryOn: 'reward memory enabled (issue-fix patch planning surface)', + rewardMemoryOff: (e) => `reward memory unavailable: ${e}`, + repoMemorySyncOn: (scope) => `repository memory seeded into OpenViking (${scope})`, + repoMemorySyncOff: (e) => `repository memory seed skipped: ${e}`, + repoMemorySyncStarted: 'repository memory indexing in the background (OpenViking) — fixes proceed meanwhile', + memoryIndexedOk: 'repository memory indexed (OpenViking)', + memoryIndexedMissing: 'repository memory not indexed: sync may have timed out; agent has no retrievable repo context', + memoryServerDown: 'OpenViking offline: repository memory unavailable', + runCancelled: 'run cancelled', + runCancelledByHost: 'turn cancelled by the host (not a manual stop) — auto-run retries with backoff', + 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.`, + turnModelHang: (m) => `Model API not responding: no first token for about ${m} minutes (the model service may have timed out / be overloaded) — cancelled the turn and allowed an automatic retry.`, + modelHangTitle: 'Model not responding — needs you', + modelHangText: (m) => `The current model has not produced a first token for about ${m} minutes (possibly rate-limited / overloaded). Switch models to retry now, or keep waiting for the automatic retry.`, + modelHangSwitch: 'Switch model', + modelHangSwitchApply: 'Switch & retry', + modelHangKeepWaiting: 'Keep waiting', + modelHangSwitched: (m) => `Switched to ${m}; retrying now`, + modelHangKeptWaiting: 'Keeping the current model; waiting for the automatic retry', + notifModelHangTitle: 'Model not responding', + notifModelHangBody: (id, m) => `${id}: no model response for about ${m} minutes — switch model or keep waiting`, + 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', + groupDecisions: 'Decisions', + groupNotices: 'Notices', + colSubDecisions: 'Blocking · continues after your approval', + colSubNotices: 'Blocked or deferred · for your awareness', + detailEmptyHint: 'Pick an in-progress goal above to view its log, or paste an issue link to create a task', + goalPickerLabel: (n) => `In-progress goals (${n})`, + goalPickerIssues: 'Fixing', + goalPickerIssuesHint: 'View the issues being fixed', + goalPickerNoIssues: 'No issue info yet', + goalPickerOpenLog: 'Open log', + goalPickerActions: 'Actions', + issueSummary: (total, done) => `${total} issues · ${done} done`, + issuePause: 'Pause', + issueResume: 'Resume', + issueRemove: 'Remove', + issueReopen: 'Reopen', + issueAdd: 'Add issue', + issueAddPlaceholder: 'Issue URL or #number', + issueAddBtn: 'Append', + issueSelected: 'Issues in the fix plan', + issueAvailable: 'Other repo issues', + issueLoading: 'Loading issues…', + issueApply: 'Apply changes', + issueApplyCount: (n) => `Apply (${n})`, + issueNoMore: 'No other open issues', + 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)`, + taskLinkPlaceholder: 'Paste a GitHub issue / repository / issues-list link', + taskNotePlaceholder: 'Additional fix requirements (optional)', + taskGuideEmpty: 'Type guidance for the agent (pick a task above first)', + taskNeedLink: 'Paste a GitHub issue / repository / issues-list link', + taskSend: 'Send', + composerModeNew: 'New fix task', + composerModeGuide: 'Guide a running task', + 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: pick the target in the goal dropdown above (or open it in the log panel) 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…', + stageWriteTodos: 'Writing fix todos', + stageResolved: 'Skipping already-resolved issue(s)…', + 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`, + notifyCount: 'FYI', + decisionCount: (n) => `${n} to decide`, + noticeCountN: (n) => `${n} FYI`, + noticeAck: 'Mark read', + notifyGroup: 'FYI', + 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', + viewOriginal: 'View original', + 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', + rejectGate: 'Reject', + rejectNote: 'User rejected this change', + rejectDone: 'Rejected this change — the task will re-plan', + 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}`, + ghLoginWorking: 'Signing in…', + ghLoginDoneShort: 'Signed in', + 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', + sectionNotify: 'FYI (no action needed)', + notifyAck: 'Mark read', + notifyAckAll: 'Mark all read', + notifyAckHint: 'Only clears the reminder; does not change the issue status', + notifyResumeHint: 'Put the issue back in the queue for the agent to retry', + notifyNoteLabel: 'Agent note (what it did + conclusion)', + notifyMore: (n) => `${n} more FYI (click to expand)`, + notifyGroupResolved: 'Resolved (no action needed)', + notifyGroupBlocked: 'Blocked (cannot continue)', + notifyGroupDeferred: "Won't fix now", + 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… (fresh session, resuming from the loopx registry)', + activityResumeSession: 'Resuming the previous session context…', + resumeReconcileTitle: 'Resume reconciliation (loopx registry, no session reuse)', + cancelTurnLogged: 'Turn cancel requested', + activityWaitingModel: 'Waiting for the model response…', + activityWaitingModelCtx: (ctx) => `Waiting for the model response… (~${ctx} context)`, + activityWaitingModelCtxEta: (ctx, eta) => `Waiting for the model response… (~${ctx} context, last ~${eta})`, + activityStartingDone: 'Agent started', + activityResumeDone: 'Session context restored', + activityModelResponded: 'Model responded', + activityModelRespondedEta: (eta) => `Model responded (${eta})`, + toolShell: 'Run command', + toolRead: 'Read', + toolSearch: 'Search', + toolWrite: 'Write', + toolEdit: 'Edit', + toolCode: 'Run code', + toolCall: 'Tool', + activityDiving: 'Bitfun is working on the bug…', + activityStalled: 'Possibly stalled (no output)', + activityModelHang: 'Model not responding (waiting)', + activityToolRunning: (name, dur) => `Tool running · ${name} · ${dur}`, + idleFor: (t) => `last output ${t} ago`, + durationMinutes: (m, s) => `${m}m ${s}s`, + durationSeconds: (s) => `${s}s`, + 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', + turnConclusionLabel: 'This turn: ', + feedbackAsk: 'Was this last turn useful?', + feedbackGood: 'Useful', + feedbackBad: 'Not useful', + feedbackDone: (r) => (r === 'positive' ? 'Recorded: useful' : 'Recorded: not useful'), + feedbackError: (e) => `Feedback failed: ${e}`, + 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', + issueResolvedBadge: 'resolved', + issueResolvedBadgeTitle: (reason) => `This issue is already closed upstream (${reason || 'closed'}) — it will be skipped and not re-fixed`, + intakeResolvedWarn: (n) => `✓ ${n} issue(s) are already resolved upstream: they will be skipped automatically and not re-fixed.`, + 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)`, + diffFilesCount: (n) => `${n} changed file${n > 1 ? 's' : ''}`, + diffStatLabel: (a, d) => `+${a} −${d}`, + diffLoading: 'Loading the code changes…', + diffEmpty: 'No code changes detected yet (nothing committed, or the branch has no diff from main).', + diffViewHunk: 'View full diff', + diffTruncated: '(diff truncated to the first 40000 chars)', + stepperPlan: 'Plan the fix', + stepperFix: 'Fix issue', + stepperPublish: 'Publish / submit PR', + stepperIssuesDone: (done, total) => `Issue ${done}/${total}`, + cancelTurn: 'Cancel turn', + turnLabel: (n, time) => `Turn #${n} · ${time}`, + turnLines: (n) => `${n} line${n > 1 ? 's' : ''}`, + emptyBoardTitle: 'Fix your first issue', + emptyBoardHint: 'Paste a GitHub issue / repository link, or enter owner/repo to browse its open issues.', + emptySampleIssue: 'Sample: single issue', + emptySampleRepo: 'Sample: repository', + emptyBrowseBtn: 'Browse', + emptyBrowsePlaceholder: 'owner/repo', + kbHint: 'Shortcuts: j/k move · Enter open · a approve · x select', + errorBannerTitle: 'Last error', + errorRetry: 'Retry', + errorClearState: 'Clear task state', + modelVisionYes: 'vision ✓', + modelVisionNo: 'vision ✗', + composerVisionHint: 'The current model cannot read screenshots; if this issue depends on images, fix quality may suffer.', + sbRunning: 'Running', + sbNeedsYou: 'Needs you', + sbQueued: 'Queued', + sbStopped: 'Stopped', + sbError: 'Errors', + sbDone: 'Done', + sbArchived: 'Archived', + queuedHint: 'Queued: waiting for loopx to schedule the next auto-run turn', + }, +}; + +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: {}, + // Cumulative input characters sent to each goal's reused agent session, and + // the observed time-to-first-response of its last turn. Both feed the + // "等待模型响应" hint so the user gets a rough ETA instead of a bare spinner. + agentInputCharsByGoal: {}, + agentTtftMsByGoal: {}, + // Authoritative context size from the host's token-usage event (inputTokens + // + maxContextTokens). This is the REAL session context — prompts + prior + // model output + tool results — not just the sum of the prompts we sent. + agentInputTokensByGoal: {}, + agentMaxContextTokensByGoal: {}, + // Non-blocking notification ack: goalId -> [acknowledged issue todoIds]. + // Blocked/deferred issues surface in the review column as FYI cards (no + // approve button); "标记已读" records the todoId here so it stops re-surfacing. + notifyAckByGoal: {}, + // Env gate: timestamp of the last time the environment (python/git/loopx/ + // OpenViking) was confirmed ready. 0 = never confirmed → boot shows the + // setup guide; > 0 = confirmed → boot skips the overlay and re-checks + // silently in the background (a later breakage re-surfaces the guide). + envReadyAt: 0, + }, + 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, + didAutoSelect: false, // 启动后自动选中第一个可操作目标,只做一次 + intakeDraft: null, + pendingIntake: null, // resolveIntake result awaiting sheet confirmation + moreOpen: new Set(), + // Which group the left board column currently shows; set by the status-bar + // tabs. Session-only UI state (not persisted). + activeBoardTab: 'decisions', + // Composer mode: 'new' (link-first intake) or 'guide' (steer a running task). + composerMode: 'new', + 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); + updateVisionHint(); +} + +// 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 modelEntrySupportsVision(entry) { + 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); +} +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]; + return modelEntrySupportsVision(entry); +} + +// #9 视觉警告提前:输入已含 GitHub 链接且当前模型无视觉能力时,在发送前就地提示。 +function updateVisionHint() { + const el = document.getElementById('composer-vision-hint'); + if (!el) return; + const input = document.getElementById('task-input'); + const text = input ? String(input.value || '') : ''; + const show = /https:\/\/github\.com\//i.test(text) && !modelSupportsVision(); + el.hidden = !show; + if (show) el.textContent = t('composerVisionHint'); +} + +// 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.composerMode === 'guide') { + const g = S.composerTargetId ? S.goals.get(S.composerTargetId) : null; + input.placeholder = g ? t('taskGuidePlaceholder', goalDisplayName(g)) : t('taskGuideEmpty'); + } else { + input.placeholder = t('taskNotePlaceholder'); + } +} + +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; +} + +// ── 双态 composer ──────────────────────────────────────────── +// 'new' = link-first intake (link input + optional note); 'guide' = steer a +// running task (textarea + target picker). The underlying resolveIntake / +// startGuidance flows are unchanged; this only swaps which input is visible. +function setComposerMode(mode) { + S.composerMode = mode === 'guide' ? 'guide' : 'new'; + const newTab = document.getElementById('mode-new'); + const guideTab = document.getElementById('mode-guide'); + const linkRow = document.getElementById('composer-link-row'); + const target = document.getElementById('composer-target'); + const submitLabel = document.getElementById('btn-create-task-label'); + const submit = document.getElementById('btn-create-task'); + if (newTab) newTab.classList.toggle('is-active', S.composerMode === 'new'); + if (guideTab) guideTab.classList.toggle('is-active', S.composerMode === 'guide'); + if (linkRow) linkRow.hidden = S.composerMode === 'guide'; + if (target) target.hidden = S.composerMode === 'new'; + if (submitLabel) submitLabel.textContent = S.composerMode === 'guide' ? t('taskSend') : t('taskCreate'); + if (submit) submit.title = S.composerMode === 'guide' ? t('taskSend') : t('taskCreate'); + if (S.composerMode === 'guide' && !S.composerTargetId) { + const running = [...S.goals.values()].filter((g) => g.running); + if (running.length === 1) setComposerTarget(running[0].goalId); + } + updateTaskPlaceholder(); + updateTaskKind(); + renderLinkUnfurl(); + setTaskFeedback(''); +} + +// Combined objective for the 'new' mode: the pasted link plus an optional +// human note. resolveIntake / parseIssueUrls already tolerate a note after +// the URL, so this preserves the old single-textarea behavior. +function composerObjective() { + const link = ((document.getElementById('composer-link-input') || {}).value || '').trim(); + const note = ((document.getElementById('task-input') || {}).value || '').trim(); + return note ? `${link}\n${note}` : link; +} + +// Lightweight link "unfurl": parse the repo + classify the link kind without a +// network round-trip (the intake sheet fetches full issue titles later). +function parseRepoLabel(text) { + const m = String(text || '').match(/github\.com\/([^/\s]+)\/([^/\s?#]+)/i); + if (!m) return null; + return `${m[1]}/${m[2].replace(/\.git$/i, '')}`; +} +function renderLinkUnfurl() { + const input = document.getElementById('composer-link-input'); + const unfurl = document.getElementById('composer-unfurl'); + if (!input || !unfurl) return; + const text = input.value.trim(); + if (!text || S.composerMode === 'guide') { + unfurl.hidden = true; + unfurl.replaceChildren(); + return; + } + const kind = taskInputKind(text); + if (!kind) { + unfurl.hidden = true; + unfurl.replaceChildren(); + return; + } + unfurl.hidden = false; + unfurl.replaceChildren(); + const repo = parseRepoLabel(text); + const line = document.createElement('span'); + line.className = 'composer__unfurl-text'; + line.textContent = repo ? `${repo} · ${kind}` : kind; + unfurl.appendChild(line); +} + +// 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; + const state = { + 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, + // #4 turn 分组:每条活动日志打上所属轮次(executeRunOnce 每次 +1)。 + turnNumber: 0, + // #3 当前动作:最近一次非静默工具/状态行,显示在详情面板标题下。 + currentAction: '', + // #7 未读门禁:本会话内新出现的审批未读标记(review 列头角标 + 脉冲)。 + gateUnread: false, + // 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, + retryAfter: 0, // 失败后自动重试的冷却截止时间戳 + lastFailReason: '', // 上一轮失败原因,注入到下一轮 prompt 供模型参考 + 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, + // 外部已解决(维护者关闭 / 合并)的 issue URL 列表 + 上次实时复核时间戳。 + externalResolved: [], + liveIssueCheckAt: 0, + memoryStatusAt: 0, // 上次仓库记忆索引进度检查时间戳 + 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, turn: e.turn || 0, + done: !!e.done, failed: !!e.failed, + })); + // 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, turn: e.turn || 0, + done: !!e.done, failed: !!e.failed, + })); + } + 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 ────────────────────────────────── +// A tick loop the scheduler stopped (unchanged×limit → stop_tick_loop) is NOT +// permanently dead: time-based conditions (quota window reset, an issue +// closed upstream, a gate approved elsewhere) can only be observed by +// polling, so stopped goals keep a low-frequency fallback poll instead of a +// "stopped ⇒ never observes change ⇒ stays stopped" deadlock. User-stopped +// goals are excluded (monitoring=false). +const STOPPED_FALLBACK_MIN = 45; + +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.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.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 { + // Stopped tick loops re-poll on the slow fallback cadence (see + // STOPPED_FALLBACK_MIN) so time-based state changes are still observed. + g.nextDueAt = Date.now() + (g.stopped ? STOPPED_FALLBACK_MIN : 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) { + // 非阻塞知会也依赖 issue 状态:为 owned goal 加载 issue 投影(60s TTL 限流), + // 让 blocked/deferred issue 能在 review 列出现为「知会」卡片。 + if (shouldTrackUserTodos(g) && goalProjectDir(g.goalId)) { + refreshGoalIssues(g); + maybeLiveIssueCheck(g); + maybeMemoryStatus(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) { + // #7 未读门禁:本会话内新出现的审批标记为未读(review 列头角标 + 脉冲)。 + g.gateUnread = true; + // 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); requestRender(true); } + }); + } 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 = {}) { + // Decision outcome for user_gate todos: approve (default) or reject. + const outcome = opts.outcome || 'approve'; + if (button) button.disabled = true; + try { + // Publish gates publish by default on approve: 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. + // 仅批准 skips the PR; 拒绝 neither publishes nor approves. + if (isPublishTodo(todo) && outcome === 'approve' && 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 || outcome === 'reject')) { + note = [note || '', outcome === 'reject' ? t('rejectNote') : 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' ? outcome : null, + }); + if (!res.ok) throw new Error(res.error || 'todo complete failed'); + const todoIsGate = todo.task_class === 'user_gate'; + const doneMsg = outcome === 'reject' ? t('rejectDone') + : (todoIsGate ? t('approveDone') : t('todoDoneFeedback')); + log(`[${g.goalId}] ${doneMsg} (${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); + g.gateUnread = false; // #7 批准即视为已读。 + 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. Failures back off +// progressively (1m/2m/3m/4m/5m) before the breaker trips at 5, so a transient +// model/network hiccup no longer parks the task after just 3 misses. +const AUTO_RUN_FAIL_LIMIT = 5; +// Auto-run concurrency cap: each goal polls independently, so N repos could +// otherwise spawn N simultaneous host agent turns (API cost + machine load +// with zero feedback). Manual 执行 is never capped — only the automatic path; +// capped-out goals simply retry on their next poll. +const MAX_CONCURRENT_AUTO_RUNS = 3; +function runningTurnCount() { + let n = 0; + for (const g of S.goals.values()) if (g.running) n += 1; + return n; +} + +// 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) { + // NOTE isHardGated, not isGated: an open publish approval gates only its own + // issue (surfaced in 需决策), while loopx keeps should_run=true so the + // goal's OTHER issues keep advancing. Blocking the whole goal here froze + // multi-issue goals on the first PR approval. + return g.autoRun && !g.running && isLiveGoal(g) && !isHardGated(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 + // Failure cooldown: after a failed turn, wait before auto-retrying. + && (!g.retryAfter || Date.now() >= g.retryAfter) + && !!goalProjectDir(g.goalId) && !!g.agentId; +} + +function maybeAutoRun(g) { + if (!canAutoRun(g)) return; + if (runningTurnCount() >= MAX_CONCURRENT_AUTO_RUNS) { + // Capped: the goal stays in backlog and fires on a later poll once a + // slot frees up (finishRun triggers pollNow on every completion). + 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; + g.retryAfter = 0; + S.config.autoRunByGoal[g.goalId] = enabled; + saveConfig(); + if (enabled) maybeAutoRun(g); +} + +// ── stop / resume task ──────────────────────────────────── +// "中止任务" is a FULL stop: the in-flight run is cancelled and its local run +// state is finalized immediately (the timer stops now, not when the async +// cancel event arrives), the loopx heartbeat (auto-poll) for this goal is +// switched off, auto-run is switched off, and the parked state persists +// across restarts. +async function stopGoalTask(g) { + S.config.autoRunBeforeStop[g.goalId] = g.autoRun === true; + if (g.running) { + // 立即停止:取消 host turn(尽力而为)并马上收尾本地运行态——计时器必须 + // 当下停住,不能等异步的 dialog-turn-cancelled 事件(可能延迟或丢失)。 + const run = agentRuns.get(g.goalId); + if (run) { try { app.agent.cancel(run.sessionId, run.turnId); } catch (_) {} } + finishRun(g, { ok: false, cancelled: true, byUser: 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), true); + 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.nextDueAt <= now) pollGoal(g); + } + rearmTimer(); +} + +// ── rendering ───────────────────────────────────────────── +// HARD gate: loopx itself says the goal cannot advance without a human +// (waiting_on=user / a gate state). This is the only condition that blocks +// auto-run — an open publish approval on ONE issue must not freeze the other +// issues of a multi-issue goal (loopx keeps waiting_on=codex + should_run=true +// precisely so the remaining issues keep moving). +function isHardGated(g) { + 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(); + return /gate|user_action|operator/.test(s); +} + +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. + if (isHardGated(g)) 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; +} + +// ── Issue 总览(方案 A:状态条 + 分组小卡)───────────────────────── +// 一个 goal 的 issue 板:把「该 goal 下所有 issue 现在各自什么状态」合并成一张 +// 可扫读的看板。loopx 的 todo 状态 open/done/blocked/deferred 是主数据,外部 +// 已解决(维护者关闭/合并)会覆盖成 resolved。 + +// 统一 issue 列表:projection 的每条 issue + externalResolved 合并去重。 +function issueBoard(g) { + const issues = (g.issues && g.issues.issues) || []; + const extByUrl = new Map(); + for (const r of (g.externalResolved || [])) if (r.url) extByUrl.set(r.url, r); + const board = []; + for (const it of issues) { + const ext = it.url ? extByUrl.get(it.url) : null; + board.push({ + url: it.url, number: it.number, title: it.title, + status: ext ? 'resolved' : (it.status || 'open'), + todoId: it.todoId, reason: it.reason, resumeWhen: it.resumeWhen, note: it.note, + external: !!ext, + }); + } + for (const r of (g.externalResolved || [])) { + if (!issues.some((it) => it.url === r.url)) { + board.push({ + url: r.url, number: r.number, title: r.title, status: 'resolved', + todoId: null, reason: null, resumeWhen: null, + note: r.stateReason ? `closed: ${r.stateReason}` : null, external: true, + }); + } + } + return board; +} + +// 分组:按「要不要用户关注」排序——无法继续/暂不修复默认展开,修复中/已解决 +// 默认折叠(数量大、无需操作)。 +function issueBoardGroups(g) { + const board = issueBoard(g); + const groups = [ + { key: 'blocked', statuses: ['blocked'], label: t('issueBlocked'), expanded: true, items: [] }, + { key: 'deferred', statuses: ['deferred'], label: t('issueDeferred'), expanded: true, items: [] }, + { key: 'open', statuses: ['open'], label: t('issueOpen'), expanded: false, items: [] }, + { key: 'resolved', statuses: ['done', 'resolved'], label: t('issueResolved'), expanded: true, items: [] }, + ]; + for (const it of board) { + const grp = groups.find((x) => x.statuses.includes(it.status)); + if (grp) grp.items.push(it); + } + return groups.filter((x) => x.items.length > 0); +} + +// 知会 exists when there is a blocked/deferred issue the human has not yet +// acknowledged. "标记已读" records the issue urls in notifyAckByGoal; a status +// change (or a brand-new blocked/deferred issue) re-surfaces it. +function noticeIssues(g) { + const acked = new Set(S.config.notifyAckByGoal[g.goalId] || []); + return issueBoard(g).filter((i) => (i.status === 'blocked' || i.status === 'deferred') && !acked.has(i.url)); +} +function hasAttentionIssues(g) { + return noticeIssues(g).length > 0; +} +function ackNotices(g) { + const urls = issueBoard(g) + .filter((i) => i.status === 'blocked' || i.status === 'deferred') + .map((i) => i.url) + .filter(Boolean); + if (!urls.length) return; + if (!S.config.notifyAckByGoal) S.config.notifyAckByGoal = {}; + S.config.notifyAckByGoal[g.goalId] = urls; + saveConfig(); + renderAllGoals(true); +} + +// 小卡第二行的结论:只对「有原因可说」的状态给一句中文,open 不额外占行。 +function issueCardConclusion(issue) { + if (issue.status === 'blocked' || issue.status === 'deferred') return notifyReasonZh(issue); + if (issue.status === 'done') return t('issueDone'); + if (issue.status === 'resolved') return t('issueResolvedExternallyShort'); + return ''; +} + +// 一张 issue 小卡 = 整卡是一个跳转链接(用户要求只留链接)。 +function buildIssueCard(g, issue) { + const card = document.createElement('a'); + card.className = `issue-card issue-card--${issue.status}`; + card.href = issue.url || '#'; + card.rel = 'noreferrer'; + card.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + openExternalUrl(issue.url); + }; + const num = document.createElement('span'); + num.className = 'issue-card__num'; + num.textContent = `#${issue.number}`; + card.appendChild(num); + const title = document.createElement('span'); + title.className = 'issue-card__title'; + const titleText = issueTitleClean(issue.title); + title.textContent = titleText; + card.appendChild(title); + const concl = issueCardConclusion(issue); + card.title = `#${issue.number}${titleText ? ` ${titleText}` : ''}${concl ? ` · ${concl}` : ''}`; + if (concl) { + const c = document.createElement('span'); + c.className = 'issue-card__concl'; + c.textContent = concl; + card.appendChild(c); + } + return card; +} + +// 整张 issue 看板:顶部状态总览条 + 按状态分组的小卡。 +// 点总览条某段:折叠组先展开,再滚动到对应分组。 +function buildIssueBoard(g) { + const groups = issueBoardGroups(g); + if (!groups.length) return null; + const board = document.createElement('div'); + board.className = 'issue-board'; + const strip = document.createElement('div'); + strip.className = 'issue-overview'; + const anchorsByKey = {}; + for (const grp of groups) { + const seg = document.createElement('button'); + seg.type = 'button'; + seg.className = `issue-overview__seg issue-overview__seg--${grp.key}`; + seg.textContent = `${grp.label} ${grp.items.length}`; + seg.title = grp.label; + seg.onclick = (ev) => { + ev.stopPropagation(); + const a = anchorsByKey[grp.key]; + if (!a) return; + if (a.details) a.details.open = true; // 折叠组先展开 + if (a.el && a.el.scrollIntoView) a.el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }; + strip.appendChild(seg); + } + board.appendChild(strip); + for (const grp of groups) { + if (grp.expanded) { + const head = document.createElement('div'); + head.className = 'issue-board__group'; + head.textContent = grp.label; + anchorsByKey[grp.key] = { el: head, details: null }; + board.appendChild(head); + const body = document.createElement('div'); + body.className = 'issue-board__items'; + for (const it of grp.items) body.appendChild(buildIssueCard(g, it)); + board.appendChild(body); + } else { + const details = document.createElement('details'); + details.className = 'issue-board__details'; + anchorsByKey[grp.key] = { el: details, details }; + const summary = document.createElement('summary'); + summary.textContent = `${grp.label} (${grp.items.length})`; + details.appendChild(summary); + const body = document.createElement('div'); + body.className = 'issue-board__items'; + for (const it of grp.items) body.appendChild(buildIssueCard(g, it)); + details.appendChild(body); + board.appendChild(details); + } + } + return board; +} + +// loopx 写的 reason/note 是英文,直接显示会让用户一头雾水。这里按 resume_when / +// reason 的关键词给一个中文归类;完整英文原文放悬停 title。 +function notifyReasonZh(issue) { + if (issue.status === 'resolved') return '已被维护者解决,无需再修复'; + const resumeWhen = String(issue.resumeWhen || ''); + const text = `${issue.reason || ''}\n${issue.note || ''}`.toLowerCase(); + // 优先用 note/reason 里的语义(owner 方向、被认领等),再退到 resume_when 的 + // 结构化条件,避免「等待其他事项完成」这种空泛归类盖过真正原因。 + if (/(owner|maintainer|direction|design|rfc|research|question|proposal|please (do not|pause|hold)|stop)/.test(text)) return '等待 owner 方向/决定'; + if (/(claim|owns|owned|duplicate|already|merged|pull request|\bpr\b|pr #)/.test(text)) return '已被他人认领/实现,不重复做'; + if (resumeWhen.startsWith('capacity_available:')) { + const cap = resumeWhen.slice('capacity_available:'.length).trim(); + return cap ? `缺少依赖/能力:${cap}` : '缺少依赖/能力'; + } + if (resumeWhen.startsWith('todo_done:')) return '等待关联事项完成后再继续'; + if (/(unavailable|not (on|installed|published)|missing|blocked)/.test(text)) return '依赖/条件不可用'; + return '暂不处理'; +} + +// 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. +// Canonical board groups in tab order. "decisions" = blocking gates that need +// the human to approve/reject; "notices" = blocked/deferred issues that are +// FYI-only. Both were previously collapsed into one "review" column. +const BOARD_GROUPS = ['decisions', 'notices', 'active', 'backlog', 'paused', 'error', 'done', 'archived']; +const GROUP_I18N_KEY = { + backlog: 'groupBacklog', active: 'groupActive', + decisions: 'groupDecisions', notices: 'groupNotices', + done: 'groupDone', paused: 'groupPaused', error: 'groupError', + archived: 'groupArchived', +}; +const GROUP_SUB_KEY = { + decisions: 'colSubDecisions', notices: 'colSubNotices', 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'; + // A human gate outranks EVERYTHING below, including a stopped tick loop: + // "decision pending for so long that polling backed off and stopped" is the + // classic long-unattended gate, and it must stay visible in 需决策 instead + // of drifting into 暂停 where nobody looks for an approval. + if (isGated(g)) return 'decisions'; + if (g.userStopped) return 'paused'; + if (g.errorCount > 0) return 'error'; + if (g.stopped) return 'paused'; + // Unread blocked/deferred issue notifications land in 知会 — FYI, not a + // decision — so the human still sees them without an approve button. A + // goal actively running stays in 运行中: the notice is reachable from the + // status bar count without hiding the live run. + if (hasAttentionIssues(g) && !g.running) return 'notices'; + 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 fmtRunDuration(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(total / 60); + const s = total % 60; + return m > 0 ? t('durationMinutes', m, String(s).padStart(2, '0')) : t('durationSeconds', s); +} + +// Compact human size helpers for the "等待模型响应" context hint. +function formatKNumber(n) { + const v = Number(n) || 0; + if (v < 1000) return String(v); + if (v < 10000) return `${(v / 1000).toFixed(1)}k`; + return `${Math.round(v / 1000)}k`; +} +function formatKChars(n) { + return formatKNumber(n) + (app.locale === 'en-US' ? ' chars' : ' 字符'); +} +// Authoritative context label: prefer the host-reported inputTokens (which +// counts prompts + prior model output + tool results), fall back to the prompt +// char count only when no token usage has been reported yet (first turn). +function contextSizeText(g, promptChars) { + const tokens = Number(S.config.agentInputTokensByGoal[g.goalId]) || 0; + const max = Number(S.config.agentMaxContextTokensByGoal[g.goalId]) || 0; + if (tokens > 0) { + const t = `${formatKNumber(tokens)} tokens`; + return max > 0 ? `${t} / ${formatKNumber(max)}` : t; + } + return formatKChars(promptChars); +} + +// 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 (isGated(g)) return { cls: 'goal-status--review', text: t('statusGated') }; + 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.retryAfter && Date.now() < g.retryAfter) { + return { cls: 'goal-status--muted', text: t('statusCooldown', Math.max(1, Math.ceil((g.retryAfter - Date.now()) / 60000))) }; + } + // 外部已解决:issue 已被维护者关闭/合并,不再需要本任务修复。 + if (Array.isArray(g.externalResolved) && g.externalResolved.length > 0) { + return { cls: 'goal-status--ok', text: t('statusExternalResolved', g.externalResolved.length) }; + } + if (!g.monitoring) return { cls: 'goal-status--muted', text: t('statusUnmonitored') }; + if (!g.autoRun) { + // Breaker tripped (5 consecutive failures): say WHY it parked instead of + // a bare 自动已关 — the failure reason is the recovery hint. + if (g.lastFailReason) { + return { cls: 'goal-status--err', text: t('statusAutoTripped'), title: g.lastFailReason }; + } + 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; + if (s.title) chip.title = s.title; + return chip; +} + +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]}/${issue[2].replace(/\.git$/i, '')}#${issue[3]}`; + const repo = text.match(/github\.com\/([^/\s]+)\/([^/\s?#]+)/i); + if (repo) return `${repo[1]}/${repo[2].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' +// 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 } +// Session-level "already computed" cache so a re-rendered (or re-created) goal +// never regenerates a summary the model already produced this session. Keyed by +// `${goalId}\u0000${todoId}`; survives goal-object churn, resets only on reload. +const summaryDoneSession = new Map(); // `${goalId}\u0000${todoId}` -> text + +// 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; + const sessionKey = `${g.goalId}\u0000${todo.todo_id}`; + if (!g.gateSummaries) g.gateSummaries = new Map(); + // Already computed this session? Re-attach to this goal object and stop — a + // re-render or a re-created goal must not trigger another model call. + const doneText = summaryDoneSession.get(sessionKey); + if (doneText != null) { + if (!g.gateSummaries.has(todo.todo_id)) g.gateSummaries.set(todo.todo_id, { status: 'done', text: doneText }); + return; + } + // 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') { + summaryDoneSession.set(sessionKey, existing.text || ''); + return; + } + if (existing && 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); + // loopx's raw gate wording (e.g. "Owner approval gate: approve implementing + // ...") is internal noise full of implementation detail — it does NOT belong + // inline on the decision card. The card shows the localized type label + the + // concise 背景/已完成/需要你 summary; the exact original stays available in + // the 批准/拒绝 confirm dialog and via the 复制 button. + 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 raw loopx wording is not shown inline (internal noise); the 复制 button + // embeds it for paste-out and the confirm dialog shows it at decision time. + return card; +} + +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) { + // Defensive trim: a model chunk can open/close with a stray newline; never + // render a leading/trailing blank line in the stream (internal newlines in + // reasoning/multi-line output are preserved). + const s = String(text || '').trim(); + 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; +} + +// #C think 实时预览:取最后一行,作为折叠行里不断变化的一行内容。 +function latestLineOf(text) { + const visible = String(text || '').trimEnd(); + const nl = visible.lastIndexOf('\n'); + return nl === -1 ? visible : visible.slice(nl + 1); +} + +// 模型流式文本里带明显错误开头 → 视为错误行标红(agent 自述错误时更醒目)。 +function isErrorText(text) { + return /^(错误|❌|⚠|失败|error\b|failed\b|✗)/i.test(String(text || '').trim()); +} + +function activityLineElement(entry) { + const row = document.createElement('div'); + row.className = 'activity-stream__line' + + (entry.isErr ? ' activity-stream__line--err' : '') + + (entry.kind === 'agent' && entry.stream && isErrorText(entry.line) ? ' activity-stream__line--err' : '') + + (entry.kind ? ` activity-stream__line--${entry.kind}` : '') + + (entry.key ? ' activity-stream__line--key' : ''); + 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 collapsed by + // default. The summary carries a LIVE one-line preview of the latest + // thinking line (like DSH ReasoningRow) so the model's progress is + // visible at a glance without expanding. + const details = document.createElement('details'); + details.className = 'activity-prompt activity-prompt--think'; + details.open = false; + const summary = document.createElement('summary'); + const label = document.createElement('span'); + label.className = 'activity-prompt__think-label'; + label.textContent = t('thinkBlockTitle'); + const sep = document.createElement('span'); + sep.className = 'activity-prompt__think-sep'; + sep.textContent = ' · '; + const preview = document.createElement('span'); + preview.className = 'activity-prompt__think-preview'; + preview.textContent = latestLineOf(cappedStreamText(entry.line, STREAM_DOM_CAPS.think)); + summary.append(label, sep, preview); + 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 if (entry.kind === 'waiting') { + // 等待阶段:转圈;完成后变为 ✓(失败/取消收尾则为 ✗)。 + const span = document.createElement('span'); + span.className = 'activity-stream__text activity-stream__text--waiting'; + const icon = document.createElement('span'); + icon.className = entry.done + ? (entry.failed ? 'status-icon status-icon--fail' : 'status-icon status-icon--done') + : 'spinner'; + if (entry.done) icon.textContent = entry.failed ? '✗' : '✓'; + const label = document.createElement('span'); + label.textContent = activityDisplayText(entry); + span.append(icon, label); + row.appendChild(span); + } else { + const text = document.createElement('span'); + text.className = 'activity-stream__text'; + if (entry.key) { + const mark = document.createElement('span'); + mark.className = 'activity-stream__keymark'; + mark.textContent = '▸ '; + text.appendChild(mark); + } + // Streamed model output stays capped too — the raw view keeps the whole + // text, the DOM only needs a readable tail window. + text.appendChild(document.createTextNode( + entry.stream + ? cappedStreamText(entry.line, STREAM_DOM_CAPS.agent) + : activityDisplayText(entry), + )); + row.appendChild(text); + } + return row; +} + +// ── #4 turn 分组 ──────────────────────────────────────────── +// A "turn" is one agent run (executeRunOnce). Entries carry entry.turn; turn +// 0 covers everything recorded outside a run (guidance, poll errors, …). +function activityTurnGroups(g) { + const groups = []; + for (const entry of g.activityLines || []) { + const turn = entry.turn || 0; + const grp = groups[groups.length - 1]; + if (!grp || grp.turn !== turn) groups.push({ turn, entries: [entry] }); + else grp.entries.push(entry); + } + return groups; +} + +function activityTurnElement(group, { latest = true } = {}) { + const details = document.createElement('details'); + details.className = 'activity-turn'; + details.dataset.turn = String(group.turn); + // 只有最新一轮默认展开:历史轮折叠成一行摘要,点开即看(Buildkite 模式)。 + details.open = latest; + const summary = document.createElement('summary'); + const caret = document.createElement('span'); + caret.className = 'activity-turn__caret'; + caret.textContent = '▶'; + const label = document.createElement('span'); + const first = group.entries[0]; + label.textContent = group.turn > 0 + ? t('turnLabel', group.turn, first ? first.time : '') + : (first ? first.time : ''); + const count = document.createElement('span'); + count.className = 'activity-turn__count'; + count.textContent = t('turnLines', group.entries.length); + summary.append(caret, label, count); + const body = document.createElement('div'); + body.className = 'activity-turn__body'; + for (const entry of group.entries) body.appendChild(activityLineElement(entry)); + details.append(summary, body); + return details; +} + +function lastActivityRow(stream) { + const sections = stream.querySelectorAll(':scope > .activity-turn'); + if (!sections.length) return null; + const body = sections[sections.length - 1].querySelector('.activity-turn__body'); + return body ? body.lastElementChild : null; +} + +// 单 turn 内 DOM 行数上限:一个长 turn(半小时、几十条工具行)如果无界累积, +// 会把 iframe 主线程拖垮(这正是日志卡在中间某行、后续停更的根因)。每个 +// turn body 只保留最近 MAX_TURN_DOM_ROWS 行,旧行滚出 DOM;完整历史仍在 +// activityLines(已按 240 行封顶)+ 持久化日志里,可重新渲染。 +const MAX_TURN_DOM_ROWS = 200; +function appendActivityEntry(stream, entry) { + const sections = stream.querySelectorAll(':scope > .activity-turn'); + const lastSection = sections.length ? sections[sections.length - 1] : null; + const entryTurn = entry.turn || 0; + if (lastSection && Number(lastSection.dataset.turn || '0') === entryTurn) { + const body = lastSection.querySelector('.activity-turn__body'); + body.appendChild(activityLineElement(entry)); + while (body.children.length > MAX_TURN_DOM_ROWS) body.removeChild(body.firstChild); + } else { + // 新轮次开始:把上一轮折叠成一行摘要(Buildkite「自动展开最后一组」模式), + // 让日志区聚焦当前轮,历史轮点开即看。 + if (lastSection) lastSection.open = false; + stream.appendChild(activityTurnElement({ turn: entryTurn, entries: [entry] })); + } + while (stream.children.length > 240) stream.removeChild(stream.firstChild); +} + +function recordGoalActivity(g, line, isErr = false, kind = null, raw = null, key = false) { + 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 into one line with a multiplier. Covers the + // default (kind-less) status lines AND tool lines — the agent legitimately + // re-runs the same state-check command (e.g. loopx quota should-run) across + // turns, and those must read as "×N", not a wall of identical rows. + const last = g.activityLines[g.activityLines.length - 1]; + const sameKind = last && ((!last.kind && !kind) || (last.kind === 'tool' && kind === 'tool')); + if (last && !last.isErr && !isErr && !last.isTick && sameKind + && 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) { + const row = lastActivityRow(stream); + const textEl = row ? row.querySelector('.activity-stream__text') : null; + if (textEl) { + // Preserve the key marker; only the label text changes. + const mark = textEl.querySelector('.activity-stream__keymark'); + textEl.replaceChildren(); + if (mark) textEl.appendChild(mark.cloneNode(true)); + textEl.appendChild(document.createTextNode(activityDisplayText(last))); + } + const timeEl = row ? row.querySelector('.activity-stream__time') : null; + if (timeEl) timeEl.textContent = now; + } + g.currentActivity = summary; + scheduleLogSave(); + return; + } + const entry = { time: now, line: summary, isErr, count: 1, kind, raw, key, turn: g.turnNumber || 0 }; + 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 actionEl = document.getElementById('goal-detail-action'); + if (actionEl && S.activeGoalId === g.goalId) { + actionEl.textContent = summary; + actionEl.hidden = false; + } + + 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(); + appendActivityEntry(stream, entry); + if (follow) streamFollowTail(stream); + } else { + const panel = document.getElementById('goal-detail-panel'); + if (!panel.hidden && S.activeGoalId === g.goalId) renderGoalDetails(g); + } + scheduleLogSave(); +} + +// Mark the last unresolved "waiting" line as done (spinner → ✓ / ✗) and patch +// the matching DOM row in place. Used when a startup/wait phase completes — +// and by finishRun for failed/cancelled turns, so no spinner spins forever in +// an old turn group. +function resolveWaiting(g, doneText, failed = false) { + if (!Array.isArray(g.activityLines)) return; + let idx = -1; + for (let i = g.activityLines.length - 1; i >= 0; i -= 1) { + if (g.activityLines[i].kind === 'waiting' && !g.activityLines[i].done) { idx = i; break; } + } + if (idx < 0) return; + const entry = g.activityLines[idx]; + entry.done = true; + if (failed) entry.failed = true; + if (doneText) entry.line = doneText; + const stream = document.querySelector(`.activity-stream[data-goal="${CSS.escape(g.goalId)}"]`); + if (stream) { + const rows = stream.querySelectorAll('.activity-stream__line--waiting'); + for (let i = rows.length - 1; i >= 0; i -= 1) { + const row = rows[i]; + if (row.classList.contains('is-done')) continue; + row.classList.add('is-done'); + const sp = row.querySelector('.spinner'); + if (sp) { + sp.className = failed ? 'status-icon status-icon--fail' : 'status-icon status-icon--done'; + sp.textContent = failed ? '✗' : '✓'; + } + const label = row.querySelector('.activity-stream__text--waiting span:last-child'); + if (label && doneText) label.textContent = doneText; + break; + } + } + scheduleLogSave(); +} + +// Resolve the "等待模型响应" spinner only when the model actually produces +// output (text stream or a tool call) — turn-start/synthetic events must not +// flip it to ✓ prematurely and make the user think the model already answered. +function markModelResponded(g) { + if (g._modelResponded) return; + g._modelResponded = true; + // 模型一旦真的开始吐字,上一张「模型未响应」行动卡即失效。 + g._modelHang = null; + // Measure the time-to-first-response and remember it: the next turn's + // "等待模型响应" hint reuses it as a real, observed ETA (context-driven). + const ttft = g._waitStartedAt ? Date.now() - g._waitStartedAt : 0; + if (ttft >= 500) { + S.config.agentTtftMsByGoal[g.goalId] = ttft; + saveConfig(); + } + resolveWaiting(g, ttft >= 500 ? t('activityModelRespondedEta', fmtRunDuration(ttft)) : t('activityModelResponded')); +} + +// 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) { + // 继续任务 → 立即打开日志面板,让用户立刻看到该任务的日志。 + openGoalDetails(g); + if (g.userStopped) { resumeGoalTask(g); return; } + if (g.stopped) { pollNow(g); return; } + setAutoRun(g, true); + pollNow(g, { force: true }); + recordGoalActivity(g, t('taskResumed', g.goalId)); + // 明确反馈:继续后若没有可推进的 issue(全部已完成/已被维护者解决),提示用户。 + if (goalHasActionableIssues(g) === false) { + setTaskFeedback(t('resumeNothingToDo'), 'ok'); + } +} + +// 该目标是否还有可推进的 issue。null = issue 数据尚未加载,无法判断。 +function goalHasActionableIssues(g) { + const board = issueBoard(g); + if (!board.length) return null; + return board.some((i) => i.status !== 'resolved' && i.status !== 'done'); +} + +function buildGoalActions(g, column) { + const box = document.createElement('span'); + box.className = 'goal-actions' + (column ? ' goal-actions--column' : ''); + // A gated goal's decision lives inline in the 需要你决定 section (approve / + // reject buttons on the gate card). The card-level buttons are only the + // lifecycle controls — 继续 / 中止 / 删除 — so there is no separate 去审批 + // button: the review column IS the decision queue. + 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, queued = false) { + const el = document.createElement('div'); + el.className = 'run-item' + (parked ? ' run-item--parked' : '') + + (queued ? ' run-item--queued' : '') + + (S.activeGoalId === g.goalId ? ' is-selected' : ''); + el.setAttribute('aria-label', g.goalId); + el.dataset.goal = g.goalId; + el.onclick = () => openGoalDetails(g); + const dot = document.createElement('span'); + dot.className = queued + ? 'dot dot--backlog' + : (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 === 'resolved') return t('issueResolvedExternallyShort'); + if (status === 'open') return t('issueOpen'); + return t('issuePending'); +} + +function isExternallyResolved(g, url) { + if (!url || !Array.isArray(g.externalResolved) || !g.externalResolved.length) return false; + return g.externalResolved.some((r) => r.url === url); +} + +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(); + // 编码诊断:把每个 issue 的 title 原样打进 debug-ui.log,用于定位中文乱码发生在哪一层。 + try { + dbgUi('goalIssues:titles', JSON.stringify((g.issues.issues || []).map((i) => ({ n: i.number, t: i.title })))); + } catch (_) {} + } 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); + refreshDetailIssues(g); // 详情面板正在展示该 goal 时同步刷新 issue 列表 + } +} + +// 外部已解决复核:轮询时向 GitHub 查一次「还有哪些未完成的 issue 其实已被 +// 维护者关闭/合并」,避免 Agent 对着已经不需要修的问题白干。worker 侧已有 +// 10min 结果缓存 + 全局限流退避,这里只再挡一道 RPC 往返。 +// 识别后的收尾动作(不是只改 UI):把对应的 open todo 标记 supersede,让 +// 下一轮 prompt 的续跑对账不再把已解决的 issue 派给 agent;每个 issue 记一条 +// 幂等的活动日志知会用户。 +function maybeLiveIssueCheck(g) { + if (Date.now() - (g.liveIssueCheckAt || 0) < 10 * 60 * 1000) return; + g.liveIssueCheckAt = Date.now(); + app.call('loopx.liveIssueCheck', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + }).then((res) => { + if (!isLiveGoal(g)) return; + const resolved = (res && res.ok && Array.isArray(res.resolved)) ? res.resolved : []; + if (!resolved.length) return; + const known = new Set((g.externalResolved || []).map((r) => r.url)); + const fresh = resolved.filter((r) => !known.has(r.url)); + if (!fresh.length) return; + g.externalResolved = (g.externalResolved || []).concat(fresh); + // 知会 + 止损:活动日志一条(含关闭原因),并 supersede 掉仍 open 的 + // 对应 todo——否则 agent 下一轮还会照着注册表继续修这个已关闭的 issue。 + const byUrl = new Map(((g.issues && g.issues.issues) || []).map((i) => [i.url, i])); + for (const r of fresh) { + recordGoalActivity(g, t('issueResolvedStopped', r.number ?? '?', r.stateReason || r.state || 'closed')); + const row = byUrl.get(r.url); + if (row && row.todoId && row.status !== 'done') { + app.call('loopx.supersedeTodo', { + argvPrefix: S.config.argvPrefix, + projectDir: goalProjectDir(g.goalId), + goalId: g.goalId, + todoId: row.todoId, + reason: `resolved upstream (${r.stateReason || r.state || 'closed'})`, + }).then((done) => { + if (done && done.ok && isLiveGoal(g)) { + refreshGoalIssues(g, true); + try { app.call('loopx.invalidateResumeCache', {}).catch(() => {}); } catch (_) {} + } + }).catch(() => {}); + } + } + renderGoal(g); + }).catch(() => {}); +} + +// 仓库记忆索引进度:启动/轮询时查一次 OpenViking 是否在线 + 该仓库 scope 是否 +// 已落库。把「同步静默失败(超时导致 scope 为空)」暴露成一条知会,而不是假装 +// 记忆可用。只跑一次(per-goal 时间戳),worker 侧不缓存(跨 goal 无共享状态)。 +function maybeMemoryStatus(g) { + const repo = goalRepoFromObjective(g); + if (!repo) return; + if (Date.now() - (g.memoryStatusAt || 0) < 30 * 60 * 1000) return; + g.memoryStatusAt = Date.now(); + app.call('loopx.memoryStatus', { repoLabel: repo }).then((res) => { + if (!isLiveGoal(g)) return; + // 状态去重:同一状态只记一次活动行,避免每 30 分钟重复刷屏。 + const state = !res || res.serverOk === false ? 'down' + : (res.resourceExists === false ? 'missing' : 'ok'); + if (g._memoryStatusLogged === state) return; + g._memoryStatusLogged = state; + if (state === 'down') { + recordGoalActivity(g, t('memoryServerDown'), true); + } else if (state === 'missing') { + recordGoalActivity(g, t('memoryIndexedMissing'), true); + } else { + recordGoalActivity(g, t('memoryIndexedOk')); + } + }).catch(() => {}); +} + +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). +// 去掉 loopx/GitHub 里的类型前缀("[Feature]: "、"[Question]: "、"【疑问】" 等), +// 给一个可读的短标题,让 issue 胶囊从「一串 #编号」变成「#编号 + 一句话说清是什么」。 +function issueTitleClean(title) { + if (!title) return ''; + return String(title) + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*[::]?\s*/i, '') + .replace(/\s+/g, ' ') + .trim(); +} +function issueTitleShort(title) { + const cleaned = issueTitleClean(title); + return cleaned.length > 30 ? `${cleaned.slice(0, 30)}…` : cleaned; +} + +// 沙箱 iframe 里 `target="_blank"` 会被拦(无 allow-popups),跳不动。 +// 统一走宿主 `app.system.openExternal` 用系统浏览器打开,失败再退 window.open。 +function openExternalUrl(url) { + if (!url) return; + try { + if (app.system && app.system.openExternal) app.system.openExternal(url); + else window.open(url, '_blank', 'noopener'); + } catch (_) { + try { window.open(url, '_blank', 'noopener'); } catch (_) {} + } +} + +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' : ''); + // brief(goal 卡片 ①):批量 goal 只给一行进度计数,不再平铺一串胶囊—— + // 截断后的英文短标题对用户是噪音,还占满整卡。单 issue 仍显示唯一胶囊。 + const briefBatch = opts.brief && !single; + 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 if (!briefBatch) { + rows.slice(0, chipLimit).forEach((row) => { + const external = isExternallyResolved(g, row.url); + const status = external + ? 'resolved' + : 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.rel = 'noreferrer'; + const short = issueTitleShort(row.info && row.info.title); + chip.textContent = short ? `#${row.number} ${short}` : `#${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.preventDefault(); ev.stopPropagation(); openExternalUrl(row.url); }; + strip.appendChild(chip); + }); + if (rows.length > chipLimit) { + const more = document.createElement('button'); + more.type = 'button'; + more.className = 'issue-chip issue-chip--more'; + const rest = rows.length - chipLimit; + more.textContent = t('moreIssues', rest); + more.title = t('moreIssuesHint', rest); + more.onclick = (ev) => { ev.stopPropagation(); openGoalDetails(g); }; + 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; +} + +// ── 紧凑看板行(左列)───────────────────────────────────────── +// The left column is a scannable queue, not an encyclopedia: one row per goal +// with the group-specific "what do I do next" in the first lines. Full context +// (issue board, gate background, log) lives in the right panel. +function buildDecisionSummary(g, blockingTodos) { + const wrap = document.createElement('div'); + wrap.className = 'board-row__summary'; + const td = blockingTodos[0]; + if (!td) { + wrap.textContent = t('groupDecisions'); + return wrap; + } + const info = gateTodoInfo(td); + const title = document.createElement('span'); + title.className = 'board-row__summary-title'; + title.textContent = info.title; + wrap.appendChild(title); + const sum = g.gateSummaries && g.gateSummaries.get(td.todo_id); + if (sum && sum.status === 'done' && sum.text) { + const line = cleanGateSummary(String(sum.text)).split('\n').filter((s) => s.trim())[0]; + if (line) { + const preview = document.createElement('span'); + preview.className = 'board-row__summary-preview'; + preview.textContent = line; + wrap.appendChild(preview); + } + } + if (blockingTodos.length > 1) { + const more = document.createElement('span'); + more.className = 'board-row__summary-more'; + more.textContent = t('moreIssues', blockingTodos.length - 1); + wrap.appendChild(more); + } + return wrap; +} + +function buildNoticeSummary(g, attentionIssues) { + const wrap = document.createElement('div'); + wrap.className = 'board-row__summary board-row__summary--notice'; + for (const it of attentionIssues.slice(0, 4)) { + const chip = document.createElement('a'); + chip.className = `issue-chip issue-chip--${it.status}`; + chip.href = it.url || '#'; + chip.rel = 'noreferrer'; + chip.textContent = `#${it.number}`; + chip.title = it.title ? `#${it.number} ${it.title}` : `#${it.number}`; + chip.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); openExternalUrl(it.url); }; + wrap.appendChild(chip); + } + if (attentionIssues.length > 4) { + const more = document.createElement('span'); + more.className = 'issue-chip issue-chip--more'; + more.textContent = t('moreIssues', attentionIssues.length - 4); + wrap.appendChild(more); + } + return wrap; +} + +function buildBoardRow(g) { + const group = goalGroup(g); + const el = document.createElement('div'); + el.className = 'board-row' + + ` board-row--${group}` + + (S.activeGoalId === g.goalId ? ' is-selected' : ''); + el.setAttribute('role', 'button'); + el.dataset.goal = g.goalId; + if (!g.archived) { + el.tabIndex = 0; + el.onclick = () => openGoalDetails(g); + } + + const dot = document.createElement('span'); + dot.className = `dot dot--${group}`; + el.appendChild(dot); + + const main = document.createElement('div'); + main.className = 'board-row__main'; + + // Title line: name + badge + status chip + lifecycle actions. + const title = document.createElement('div'); + title.className = 'board-row__title'; + const name = document.createElement('span'); + name.className = 'board-row__name'; + name.textContent = goalDisplayName(g); + name.title = g.goalId; + title.appendChild(name); + const todos = Array.isArray(g.userTodos) ? g.userTodos : []; + const blockingTodos = todos.filter((td) => gateTodoInfo(td).isBlocking); + const attentionIssues = noticeIssues(g); + if (group === 'decisions' && blockingTodos.length > 0) { + const badge = document.createElement('span'); + badge.className = 'board-row__badge board-row__badge--decision'; + badge.textContent = t('decisionCount', blockingTodos.length); + title.appendChild(badge); + } else if (group === 'notices' && attentionIssues.length > 0) { + const badge = document.createElement('span'); + badge.className = 'board-row__badge board-row__badge--notice'; + badge.textContent = t('noticeCountN', attentionIssues.length); + title.appendChild(badge); + } + title.appendChild(goalStatusChip(g)); + const actions = document.createElement('span'); + actions.className = 'board-row__actions'; + if (!g.archived) { + actions.appendChild(buildGoalActions(g, false)); + } else { + const restore = document.createElement('button'); + restore.type = 'button'; + restore.className = 'btn btn--tiny btn--primary'; + restore.textContent = t('restoreTask'); + restore.onclick = (ev) => { ev.stopPropagation(); restoreArchivedGoal(g, restore); }; + actions.appendChild(restore); + } + title.appendChild(actions); + main.appendChild(title); + + // Sub line: the scannable "what is happening / what needs you". + const sub = document.createElement('div'); + sub.className = 'board-row__sub'; + if (group === 'decisions') { + sub.appendChild(buildDecisionSummary(g, blockingTodos)); + } else if (group === 'notices') { + sub.appendChild(buildNoticeSummary(g, attentionIssues)); + } else if (group === 'done') { + sub.textContent = goalConclusion(g); + } else if (group === 'archived') { + sub.textContent = t('archivedHint'); + } else { + const narration = goalNarration(g); + if (narration) { + const n = document.createElement('div'); + n.className = 'board-row__narration'; + n.textContent = narration; + sub.appendChild(n); + } + const strip = buildIssueStrip(g, { rail: true }); + if (strip) sub.appendChild(strip); + if (g.currentActivity) { + const act = document.createElement('div'); + act.className = 'board-row__activity'; + act.textContent = g.currentActivity; + sub.appendChild(act); + } + if (group === 'error' && g.lastError) { + const err = document.createElement('div'); + err.className = 'board-row__error'; + err.textContent = g.lastError; + err.title = g.lastError; + sub.appendChild(err); + } + } + main.appendChild(sub); + + // Decision line (需决策 only): inline approve button(s) bound to the first + // blocking gate(s); full diff/context opens in the right panel. + if (group === 'decisions') { + const decision = document.createElement('div'); + decision.className = 'board-row__decision'; + for (const td of blockingTodos.slice(0, 2)) { + const info = gateTodoInfo(td); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn--tiny btn--approve'; + btn.textContent = info.isPublish ? t('approveAndPr') : t('approveGate'); + btn.onclick = (ev) => { ev.stopPropagation(); openApproveDialog(g, td); }; + decision.appendChild(btn); + } + if (blockingTodos.length > 2) { + const more = document.createElement('span'); + more.className = 'board-row__decision-more'; + more.textContent = `+${blockingTodos.length - 2}`; + decision.appendChild(more); + } + main.appendChild(decision); + } else if (group === 'notices') { + const ack = document.createElement('button'); + ack.type = 'button'; + ack.className = 'btn btn--tiny'; + ack.textContent = t('noticeAck'); + ack.onclick = (ev) => { ev.stopPropagation(); ackNotices(g); }; + main.appendChild(ack); + } + + el.appendChild(main); + return el; +} + +// ── #2 阶段流水线 stepper(横向内联,放进标题行右侧空白区) ── +function stepperInlineStep(label, state) { + const s = document.createElement('span'); + s.className = `stepper-inline__step stepper-inline__step--${state}`; + s.textContent = label; + return s; +} + +function buildGoalStepperContent(g) { + if (!objectiveHasIssueSignal(g.objective || '')) return null; + const terminal = isTerminal(g); + const issues = (g.issues && g.issues.issues) || []; + const doneN = issues.filter((i) => i.done).length; + const totalN = issues.length; + const publishGated = isGated(g) && Array.isArray(g.userTodos) + && g.userTodos.some((td) => isPublishTodo(td)); + const started = terminal || g.running || g.lastRun || doneN > 0; + + const wrap = document.createElement('span'); + wrap.className = 'stepper-inline'; + const sep = () => { + const s = document.createElement('span'); + s.className = 'stepper-inline__sep'; + s.textContent = '›'; + return s; + }; + wrap.appendChild(stepperInlineStep((terminal || started ? '✓ ' : '') + t('stepperPlan'), terminal || started ? 'done' : 'active')); + wrap.appendChild(sep()); + const fixLabel = totalN > 0 ? `${t('stepperFix')} ${doneN}/${totalN}` : t('stepperFix'); + const fixState = terminal ? 'done' : (publishGated ? 'done' : (started ? 'active' : 'pending')); + wrap.appendChild(stepperInlineStep(fixLabel, fixState)); + wrap.appendChild(sep()); + const pubState = terminal ? 'done' : (publishGated ? 'active' : 'pending'); + wrap.appendChild(stepperInlineStep(t('stepperPublish'), pubState)); + return wrap; +} + +function renderGoalStepperInline(g) { + const el = document.getElementById('goal-detail-stepper'); + if (!el) return; + const content = buildGoalStepperContent(g); + if (!content) { + el.hidden = true; + el.replaceChildren(); + return; + } + el.hidden = false; + el.replaceChildren(content); + const single = parseIssueUrls(g.objective || '').length === 1; + if (!single && !g.issues && !g.issuesLoading) refreshGoalIssues(g); +} + +// ── #8 错误恢复横幅 ──────────────────────────────────────── +// 失败不是只给一个红色 chip:给出最后错误 + 可执行动作(重试 / 清状态)。 +function buildGoalErrorBanner(g) { + if (g.errorCount <= 0 && !g.lastError) return null; + const banner = document.createElement('div'); + banner.className = 'goal__error-banner'; + const title = document.createElement('div'); + title.className = 'goal__section-label'; + title.textContent = `${t('errorBannerTitle')} ×${g.errorCount || 1}`; + banner.appendChild(title); + const text = document.createElement('div'); + text.className = 'goal__error-text'; + text.textContent = g.lastError || t('activityFailed'); + text.title = g.lastError || ''; + banner.appendChild(text); + const actions = document.createElement('div'); + actions.className = 'goal__error-actions'; + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'btn btn--tiny btn--primary'; + retry.textContent = t('errorRetry'); + retry.onclick = () => { g.errorCount = 0; g.lastError = null; pollNow(g, { force: true }); }; + actions.appendChild(retry); + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'btn btn--tiny'; + clear.textContent = t('errorClearState'); + clear.onclick = () => openResetConfirm(); + actions.appendChild(clear); + banner.appendChild(actions); + return banner; +} + +function renderGoalErrorBanner(g, body) { + let banner = body.querySelector('.goal__error-banner'); + const fresh = buildGoalErrorBanner(g); + if (!fresh) { + if (banner) banner.remove(); + return; + } + if (!banner) { + banner = document.createElement('div'); + banner.className = 'goal__error-banner'; + body.insertBefore(banner, body.firstChild); + } + banner.replaceChildren(); + while (fresh.firstChild) banner.appendChild(fresh.firstChild); +} + +// ── 模型未响应行动卡 ──────────────────────────────────────── +// 模型长时间不返回第一个字时,不是只把 run 取消 + 自动重试:弹一张非阻塞行动卡, +// 给用户「切换模型立即重试」或「继续等待自动重试」两个选择。卡是提示、不拦截轮询, +// 所以无论用户是否点,自动重试照常进行;点了切换模型则立刻换模型重跑。 +function modelHangSig(g) { + return g._modelHang ? `${g.goalId}|${g._modelHang.idleMin}|${g._modelHang.model}` : ''; +} + +// 把某目标已选模型写入 per-goal 覆盖并立即重跑(不碰全局默认,只救当前卡住的任务)。 +async function applyModelHangSwitch(g, newModel) { + if (newModel && newModel !== 'auto') { + S.config.modelByGoal[g.goalId] = newModel; + } else if (newModel === 'auto') { + // 「自动」也作为显式覆盖写入,表示该任务明确跟随 BitFun 策略。 + S.config.modelByGoal[g.goalId] = 'auto'; + } + await saveConfig(); + // 清除失败冷却,让本轮立刻重跑而不是等 autoFailCount 分钟的退避。 + g.errorCount = 0; + g.lastError = null; + g.autoFailCount = 0; + g.retryAfter = 0; + g._modelHang = null; + const name = newModel || 'auto'; + log(`[${g.goalId}] ${t('modelHangSwitched', name)}`); + recordGoalActivity(g, t('modelHangSwitched', name), false, 'agent'); + renderGoalDetails(g); + requestRender(); + pollNow(g, { force: true }); +} + +function buildModelHangCard(g) { + if (!g._modelHang) return null; + const card = document.createElement('div'); + card.className = 'goal__model-hang'; + const title = document.createElement('div'); + title.className = 'goal__section-label'; + title.textContent = t('modelHangTitle'); + card.appendChild(title); + const text = document.createElement('div'); + text.className = 'goal__model-hang-text'; + text.textContent = t('modelHangText', g._modelHang.idleMin); + card.appendChild(text); + const actions = document.createElement('div'); + actions.className = 'goal__model-hang-actions'; + const select = document.createElement('select'); + select.className = 'goal__model-hang-select'; + fillModelSelect(select, modelForGoal(g.goalId), false); + actions.appendChild(select); + const apply = document.createElement('button'); + apply.type = 'button'; + apply.className = 'btn btn--tiny btn--primary'; + apply.textContent = t('modelHangSwitchApply'); + apply.onclick = () => applyModelHangSwitch(g, select.value); + actions.appendChild(apply); + const wait = document.createElement('button'); + wait.type = 'button'; + wait.className = 'btn btn--tiny'; + wait.textContent = t('modelHangKeepWaiting'); + wait.onclick = () => { + g._modelHang = null; + log(`[${g.goalId}] ${t('modelHangKeptWaiting')}`); + recordGoalActivity(g, t('modelHangKeptWaiting'), false, 'agent'); + renderGoalDetails(g); + requestRender(); + }; + actions.appendChild(wait); + card.appendChild(actions); + return card; +} + +function renderModelHangCard(g, body) { + let card = body.querySelector('.goal__model-hang'); + const fresh = buildModelHangCard(g); + if (!fresh) { + if (card) card.remove(); + return; + } + // 只要没换目标、没换卡内容,就不重建(select 正在被用户操作时不能丢焦点)。 + if (card && card.dataset.sig === modelHangSig(g)) return; + if (!card) { + card = document.createElement('div'); + card.className = 'goal__model-hang'; + const errBanner = body.querySelector('.goal__error-banner'); + body.insertBefore(card, errBanner ? errBanner.nextSibling : body.firstChild); + } + card.dataset.sig = modelHangSig(g); + card.replaceChildren(); + while (fresh.firstChild) card.appendChild(fresh.firstChild); +} + +// ── 决策工作台 ────────────────────────────────────────────── +// The right panel's content for 需决策/知会 goals: full gate context (and +// diff-first approval via the gate card's 批准 button) above the live log. +function decisionFingerprint(g) { + const blocking = (g.userTodos || []).filter((td) => gateTodoInfo(td).isBlocking).map((td) => td.todo_id).join(','); + const notices = noticeIssues(g).map((i) => i.url).join(','); + const summaries = [...(g.gateSummaries || new Map())].map(([k, v]) => `${k}:${v && v.status}`).join(','); + return `${blocking}|${notices}|${summaries}`; +} + +function renderDecisionWorkspace(g) { + const body = document.getElementById('goal-detail-body'); + if (!body) return; + const sig = decisionFingerprint(g); + let ws = body.querySelector(':scope > .decision-workspace'); + if (ws && ws.dataset.sig === sig) return; // unchanged: keep scroll/focus + if (!ws) { + ws = document.createElement('div'); + ws.className = 'decision-workspace'; + body.insertBefore(ws, body.firstChild); + } + ws.dataset.sig = sig; + ws.replaceChildren(); + + const group = goalGroup(g); + const head = document.createElement('div'); + head.className = 'decision-workspace__head'; + head.textContent = group === 'decisions' ? t('sectionDecision') : t('groupNotices'); + ws.appendChild(head); + + if (group === 'decisions') { + const blocking = (g.userTodos || []).filter((td) => gateTodoInfo(td).isBlocking); + if (blocking.length) { + const list = document.createElement('div'); + list.className = 'gate-items'; + for (const td of blocking) list.appendChild(buildGateItemCard(g, td)); + ws.appendChild(list); + } else { + const none = document.createElement('div'); + none.className = 'goal__gate-none'; + none.textContent = t('gateEmptyHint'); + ws.appendChild(none); + } + } + const board = buildIssueBoard(g); + if (board) ws.appendChild(board); + if (group === 'notices') { + const ack = document.createElement('button'); + ack.type = 'button'; + ack.className = 'btn btn--small'; + ack.textContent = t('noticeAck'); + ack.onclick = () => ackNotices(g); + ws.appendChild(ack); + } +} + +function removeDecisionWorkspace(body) { + const ws = body && body.querySelector(':scope > .decision-workspace'); + if (ws) ws.remove(); +} + +// ── Issues 管理 ────────────────────────────────────────────── +// 进行中/排队/暂停/异常 目标的右侧面板在日志上方渲染 issue 管理(勾选增删)。 +// 旧「进行中下拉框」的二级菜单内容移到这里,功能保留。 +function issueManagementSig(g) { + const issues = (g.issues && g.issues.issues) || []; + return issues.map((i) => `${i.url}:${i.status}:${i.todoId || ''}`).join(','); +} + +function renderIssueManagement(g) { + const body = document.getElementById('goal-detail-body'); + if (!body) return; + const group = goalGroup(g); + const show = group === 'active' || group === 'backlog' || group === 'paused' || group === 'error'; + let sec = body.querySelector(':scope > .detail-issues'); + if (!show) { + if (sec) sec.remove(); + return; + } + const sig = issueManagementSig(g); + if (!sec) { + sec = document.createElement('div'); + sec.className = 'detail-issues'; + sec.dataset.goalId = g.goalId; + const stream = body.querySelector(':scope > .activity-stream'); + body.insertBefore(sec, stream || body.firstChild); + } else if (sec.dataset.goalId !== g.goalId) { + sec.dataset.goalId = g.goalId; + sec.dataset.sig = ''; + } + if (sec.dataset.sig === sig) return; + sec.dataset.sig = sig; + resetGoalPickerSelection(g); + sec.replaceChildren(); + // 总览:所有 issue 按状态分组(受阻/暂不修复/修复中/已解决),一眼看到全貌。 + const board = buildIssueBoard(g); + if (board) sec.appendChild(board); + sec.appendChild(buildGoalPickerDetailContent(g)); +} + +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); + } + // #2 阶段流水线改为横向内联,放进标题行右侧的空白区(不再占 body 高度)。 + renderGoalStepperInline(g); + // #3 当前动作标题行 + 内联停止任务。 + const actionEl = document.getElementById('goal-detail-action'); + if (actionEl) { + actionEl.textContent = g.currentActivity || ''; + actionEl.hidden = !g.currentActivity; + } + const body = document.getElementById('goal-detail-body'); + + // #8 错误恢复横幅在日志上方。 + renderGoalErrorBanner(g, body); + // 模型未响应行动卡紧随其后(有错误横幅时放在其下方,否则置顶)。 + renderModelHangCard(g, body); + + // 决策工作台:需决策/知会 目标在日志上方渲染完整审批上下文;其它组别的右侧 + // 面板保持纯日志 + issue 管理(进行中/排队/暂停/异常)。 + const decisionMode = group === 'decisions' || group === 'notices'; + if (decisionMode) { + renderDecisionWorkspace(g); + } else { + removeDecisionWorkspace(body); + renderIssueManagement(g); + } + + // The panel's log groups by turn (one collapsible section per run). Gate + // items live on the review cards; the log stays the live activity surface. + let stream = body.querySelector('.activity-stream'); + const groups = activityTurnGroups(g); + const sectionCount = stream ? stream.querySelectorAll(':scope > .activity-turn').length : 0; + const domRows = stream ? stream.querySelectorAll(':scope > .activity-turn .activity-stream__line').length : 0; + const fresh = !stream || stream.dataset.goal !== g.goalId + || sectionCount > groups.length + || domRows > g.activityLines.length; + + if (fresh) { + if (stream) stream.remove(); + stream = document.createElement('div'); + stream.className = 'activity-stream activity-stream--panel'; + stream.dataset.goal = g.goalId; + if (groups.length > 0) { + for (let i = 0; i < groups.length; i += 1) { + stream.appendChild(activityTurnElement(groups[i], { latest: i === groups.length - 1 })); + } + } 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. In + // decision mode the workspace (not the log tail) is the primary surface, + // so stay at the top instead. + body.scrollTop = decisionMode ? 0 : body.scrollHeight; + updateLogBottomBtn(); + requestAnimationFrame(() => { + for (const pre of stream.querySelectorAll('.activity-prompt--think pre')) { + pre.scrollTop = pre.scrollHeight; + } + body.scrollTop = decisionMode ? 0 : body.scrollHeight; + updateLogBottomBtn(); + }); + return; + } + + // In-sync stream: append only the missing turn sections / entries. + const follow = streamAtTail(stream); + const sections = stream.querySelectorAll(':scope > .activity-turn'); + if (sections.length < groups.length) { + // 新轮次的分组补进来时,折叠此前的最后一组,保持只展开当前轮。 + if (sections.length > 0) sections[sections.length - 1].open = false; + for (let i = sections.length; i < groups.length; i += 1) { + stream.appendChild(activityTurnElement(groups[i], { latest: i === groups.length - 1 })); + } + } else if (sections.length === groups.length && groups.length > 0) { + const lastGroup = groups[groups.length - 1]; + const lastBody = sections[sections.length - 1].querySelector('.activity-turn__body'); + while (lastBody.children.length < lastGroup.entries.length) { + const row = activityLineElement(lastGroup.entries[lastBody.children.length]); + lastBody.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). +// ── #1 diff-first 审批 ───────────────────────────────────── +// 发布门禁在批准前加载分支改动(文件 + 每文件 +/- 行数 + 可展开的 unified +// diff),复用 worker 的 loopx.gitDiff(numstat + hunks),把"看懂了再批准" +// 变成默认流程,而不是盲批一份三行 AI 摘要。 +async function loadApproveDiff(g, branch = null) { + const container = document.getElementById('approve-diff'); + if (!container) return; + const countEl = document.getElementById('approve-diff-count'); + const statEl = document.getElementById('approve-diff-stat'); + const listEl = document.getElementById('approve-diff-list'); + const hunkEl = document.getElementById('approve-diff-hunk'); + const hunkSummary = document.getElementById('approve-diff-hunk-summary'); + const hunkBody = document.getElementById('approve-diff-hunk-body'); + container.hidden = false; + countEl.textContent = t('diffLoading'); + statEl.textContent = ''; + listEl.replaceChildren(); + hunkEl.hidden = true; + try { + // Publish gates name a per-issue branch; the human must review THAT + // branch's changes, not whatever HEAD happens to be checked out (another + // issue's branch, or the pristine default branch showing "no changes"). + const res = await app.call('loopx.gitDiff', { + projectDir: goalProjectDir(g.goalId), + branch: branch || null, + }); + if (!res || !res.ok) throw new Error((res && res.error) || 'diff failed'); + const files = (Array.isArray(res.numstat) && res.numstat.length) + ? res.numstat + : (res.files || []).map((p) => ({ path: p, added: 0, deleted: 0 })); + countEl.textContent = t('diffFilesCount', files.length); + // Show which branch is being compared so the user can confirm the diff + // matches the branch the PR will publish. + statEl.textContent = [branch ? t('diffBranchLabel', branch) : '', res.stat || ''] + .filter(Boolean).join(' · '); + if (!files.length) { + const empty = document.createElement('div'); + empty.className = 'approve-diff__loading'; + empty.textContent = t('diffEmpty'); + listEl.appendChild(empty); + } else { + for (const f of files.slice(0, 60)) { + const row = document.createElement('div'); + row.className = 'approve-diff__file'; + const path = document.createElement('span'); + path.className = 'approve-diff__file-path'; + path.textContent = f.path; + path.title = f.path; + const add = document.createElement('span'); + add.className = 'approve-diff__file-num approve-diff__file-num--add'; + add.textContent = `+${f.added}`; + const del = document.createElement('span'); + del.className = 'approve-diff__file-num approve-diff__file-num--del'; + del.textContent = `−${f.deleted}`; + row.append(path, add, del); + listEl.appendChild(row); + } + if (files.length > 60) { + const more = document.createElement('div'); + more.className = 'approve-diff__loading'; + more.textContent = t('moreIssues', files.length - 60); + listEl.appendChild(more); + } + } + if (res.hunks) { + hunkSummary.textContent = t('diffViewHunk'); + hunkBody.textContent = res.hunks; + hunkEl.hidden = false; + } + } catch (err) { + countEl.textContent = t('diffEmpty'); + statEl.textContent = ''; + const errEl = document.createElement('div'); + errEl.className = 'approve-diff__loading'; + errEl.textContent = String(err.message || err); + listEl.appendChild(errEl); + } +} + +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 loopx original wording is the authoritative decision subject, but it is + // verbose internal detail — collapse it behind 查看原文 so the dialog leads + // with the concise summary and shows the full text only on demand. + if (raw) { + const details = document.createElement('details'); + details.className = 'gate-card__raw-details'; + const summary = document.createElement('summary'); + summary.textContent = t('viewOriginal'); + const rawEl = document.createElement('div'); + rawEl.className = 'gate-card__raw'; + rawEl.textContent = raw; + details.append(summary, rawEl); + approveText.appendChild(details); + } + 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), approve + // without a PR, or reject the change outright — 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; + } + // Blocking decisions (user_gate / publish) support an explicit 拒绝, so + // "同意或拒绝" is a real choice — not approve-or-cancel. + const rejectBtn = dlg.querySelector('#btn-approve-reject'); + if (rejectBtn) { + const blocking = isGate || isPublish; + rejectBtn.hidden = !blocking; + if (blocking) { + rejectBtn.textContent = t('rejectGate'); + rejectBtn.onclick = () => { + dlg.returnValue = 'reject'; + dlg.close('reject'); + }; + } + } + const noteInput = document.getElementById('approve-note'); + noteInput.value = ''; + // #1 diff-first 审批:发布门禁先加载并展示改动文件,看懂了再批准。 + // Diff 的对象必须是 gate 指定的待发布分支(publishPr 用的同一个解析), + // 而不是工作区恰好 checkout 的 HEAD。 + const diffContainer = document.getElementById('approve-diff'); + if (isPublish) loadApproveDiff(g, branchHintFromText(todo.text || todo.title || '')); + else if (diffContainer) diffContainer.hidden = true; + 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 }); + else if (dlg.returnValue === 'reject') approveTodo(g, todo, noteInput.value.trim(), null, { outcome: 'reject' }); + }; + 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); + const goalId = g.goalId; + // #B 乐观移除:先从看板立即消失,后台再执行删除;失败时 refreshGoals 会把 + // 目标重新拉回来(config 映射只在成功后才清理),避免「卡很久 + 卡片闪一下」。 + S.goals.delete(goalId); + if (S.activeGoalId === goalId) { + S.activeGoalId = null; + document.getElementById('goal-detail-panel').hidden = true; + document.getElementById('detail-empty').hidden = false; + } + renderAllGoals(true); + setTaskFeedback(t('taskDeleted', name), 'ok'); + try { + const res = await app.call('loopx.deleteGoal', { + argvPrefix: S.config.argvPrefix, + srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(goalId), + goalId, + }); + if (!res.ok) throw new Error(res.error || 'delete failed'); + log(`[${goalId}] ${t('taskDeleted', name)}`); + if (res.warning) log(`[${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[goalId]; + } + await saveConfig(); + await refreshGoals(); + saveLogs(); // the removed goal's persisted log drops out of the snapshot + renderAllGoals(true); + } catch (err) { + const message = String(err && err.message || err); + log(`[${goalId}] delete failed: ${message}`, true); + // Restore the goal so the board reflects the still-on-disk registry entry. + try { await refreshGoals(); } catch (_) {} + renderAllGoals(true); + 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 +} + +// The run/stop toggle now lives next to the "Bitfun努力解bug中…" status bar +// (see startCountdownLoop); the panel footer button dispatches on the selected +// goal's current state — stop while running, resume while paused. +document.getElementById('btn-detail-toggle-run').addEventListener('click', () => { + const g = S.activeGoalId ? S.goals.get(S.activeGoalId) : null; + if (!g) return; + if (g.running) openStopConfirm(g); + else if (g.userStopped || g.stopped || !g.autoRun) resumeCardTask(g); +}); + +document.getElementById('btn-close-goal').addEventListener('click', () => { + S.activeGoalId = null; + document.getElementById('goal-detail-panel').hidden = true; + renderAllGoals(true); +}); + +// #7 点击某个任务的卡片即视为已读该任务的门禁——不再一次清空所有任务的未读 +// 标记(点 A 不该把 B、C 的未读也清掉)。 +document.getElementById('review-zone').addEventListener('click', (e) => { + const row = e.target && e.target.closest ? e.target.closest('[data-goal]') : null; + const goalId = row ? row.dataset.goal : null; + const g = goalId ? S.goals.get(goalId) : null; + if (g && g.gateUnread) { + g.gateUnread = false; + requestRender(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); + } +}); + +// ── #10 全局状态条 ──────────────────────────────────────── +function reviewUnreadCount() { + let n = 0; + for (const g of S.goals.values()) if (g.gateUnread) n += 1; + return n; +} + +function onStatusBarClick(group) { + // The status bar is the board's tab switcher: clicking a segment sets which + // group the left column renders. + if (!BOARD_GROUPS.includes(group)) return; + if (S.activeBoardTab === group) return; + S.activeBoardTab = group; + renderAllGoals(true); +} + +// ── #D 进行中目标下拉框(取代旧的「进行中」列) ─────────────── +// 列出 running / queued / parked / error 目标;点击目标选中并打开日志, +// 每个目标右侧的 › 弹二级菜单显示正在修复的 issues。 +// 详情面板正在展示某 goal 时,issue 投影加载完要同步刷新「Issues」区块。 +function refreshDetailIssues(g) { + const body = document.getElementById('goal-detail-body'); + if (!body || !g || S.activeGoalId !== g.goalId) return; + const sec = body.querySelector(':scope > .detail-issues'); + if (!sec) return; + sec.dataset.sig = ''; + renderIssueManagement(g); +} + +// 收集 goal 的 issue 行(url/number/info),null = 投影尚未加载。 +function goalIssueRows(g) { + const projection = (g.issues && g.issues.issues) || []; + const urls = parseIssueUrls(g.objective || ''); + const byUrl = new Map(); + for (const issue of projection) if (issue.url) byUrl.set(issue.url, issue); + if (urls.length) { + const rows = urls.map((u) => ({ url: u.url, number: u.number, info: byUrl.get(u.url) || null })); + for (const issue of projection) { + if (!urls.some((u) => u.url === issue.url)) rows.push({ url: issue.url, number: issue.number, info: issue }); + } + return rows; + } + if (g.issues) return projection.map((issue) => ({ url: issue.url, number: issue.number, info: issue })); + return null; +} + +function issueRowStatus(g, row) { + const urls = parseIssueUrls(g.objective || ''); + if (urls.length === 1) return goalGroup(g) === 'done' ? 'done' : 'open'; + return row.info ? row.info.status : 'pending'; +} + +function goalRepoFromObjective(g) { + const m = String(g.objective || '').match(/github\.com\/([^/\s]+\/[^/\s?#]+)/i); + return m ? m[1].replace(/\.git$/i, '') : null; +} + +// ── issue 清单:勾选 + 批量应用(取代旧的行内操作和「新增 issue」输入框) ── +const goalPickerRepoIssues = new Map(); // repo -> { issues, truncated, at, error? } +let goalPickerSelection = { goalId: null, add: new Set(), remove: new Set() }; + +function resetGoalPickerSelection(g) { + if (goalPickerSelection.goalId !== g.goalId) { + goalPickerSelection = { goalId: g.goalId, add: new Set(), remove: new Set() }; + } +} + +async function fetchRepoIssues(repo) { + const cached = goalPickerRepoIssues.get(repo); + if (cached && Date.now() - cached.at < 60000) return cached; + try { + const res = await app.call('loopx.listRepoIssues', { repo }); + if (!res || !res.ok) throw new Error((res && res.error) || 'list repo issues failed'); + const entry = { issues: res.issues || [], truncated: !!res.truncated, at: Date.now() }; + goalPickerRepoIssues.set(repo, entry); + return entry; + } catch (err) { + return { issues: [], truncated: false, at: Date.now(), error: String(err.message || err) }; + } +} + +function goalPickerSectionHead(text) { + const h = document.createElement('div'); + h.className = 'goal-picker__section-head'; + h.textContent = text; + return h; +} + +// 加载中:转圈 spinner + 文字,替代「…」。 +function loadingEl(text) { + const el = document.createElement('div'); + el.className = 'loading'; + const sp = document.createElement('span'); + sp.className = 'spinner'; + const label = document.createElement('span'); + label.textContent = text; + el.append(sp, label); + return el; +} + +function buildCheckRow(g, row, wasSelected) { + const item = document.createElement('label'); + item.className = 'goal-picker__checkrow'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = wasSelected ? !goalPickerSelection.remove.has(row.url) : goalPickerSelection.add.has(row.url); + cb.onchange = () => { + if (wasSelected) { + if (goalPickerSelection.remove.has(row.url)) goalPickerSelection.remove.delete(row.url); + else goalPickerSelection.remove.add(row.url); + } else { + if (goalPickerSelection.add.has(row.url)) goalPickerSelection.add.delete(row.url); + else goalPickerSelection.add.add(row.url); + } + refreshGoalPickerApplyButton(); + }; + const mark = document.createElement('span'); + const status = wasSelected ? (row.status || 'open') : 'available'; + mark.className = `goal-picker__issue-mark goal-picker__issue-mark--${status}`; + mark.textContent = status === 'done' ? '✓' : (status === 'blocked' ? '✗' : (status === 'deferred' ? '◌' : '')); + const num = document.createElement('span'); + num.className = 'goal-picker__issue-num'; + num.textContent = `#${row.number}`; + const title = document.createElement('span'); + title.className = 'goal-picker__issue-title'; + title.textContent = row.title || ''; + title.title = row.title || ''; + item.append(cb, mark, num, title); + // 已选中的 issue 带上文字状态(受阻/已修复/已搁置),符号单独看不明确;受阻/已搁置 + // 再附上原因,说明「受阻 ≠ 需要审批」,而是 agent 记录的具体卡点。 + if (status !== 'available') { + const statusText = document.createElement('span'); + statusText.className = `goal-picker__issue-status goal-picker__issue-status--${status}`; + statusText.textContent = issueStatusLabel(status); + const reason = row.reason; + if ((status === 'blocked' || status === 'deferred') && reason) { + const compact = String(reason).replace(/\s+/g, ' ').trim(); + statusText.textContent += ` · ${compact.length > 44 ? `${compact.slice(0, 44)}…` : compact}`; + const full = (row.note && String(row.note).trim()) || compact; + statusText.title = full; + } + item.append(statusText); + } + return item; +} + +function refreshGoalPickerApplyButton() { + const btn = document.querySelector('.goal-picker__apply'); + if (!btn) return; + const n = goalPickerSelection.add.size + goalPickerSelection.remove.size; + btn.disabled = n === 0; + btn.textContent = n > 0 ? t('issueApplyCount', n) : t('issueApply'); +} + +function fillIssueChecklist(g, container) { + container.replaceChildren(); + // 触发加载 todo 投影(已选 issue 来源),加载完会经 refreshGoalPickerDetail 重渲染。 + if (!g.issues && !g.issuesLoading) refreshGoalIssues(g); + const current = (g.issues && g.issues.issues) || []; + const selectedUrls = new Set(current.map((i) => i.url).filter(Boolean)); + + if (current.length) { + container.appendChild(goalPickerSectionHead(t('issueSelected'))); + for (const issue of current) { + container.appendChild(buildCheckRow(g, { url: issue.url, number: issue.number, title: issue.title || '', status: issue.status, reason: issue.reason, note: issue.note }, true)); + } + } + + container.appendChild(goalPickerSectionHead(t('issueAvailable'))); + const availWrap = document.createElement('div'); + availWrap.className = 'goal-picker__available'; + container.appendChild(availWrap); + availWrap.appendChild(loadingEl(t('issueLoading'))); + + const repo = goalRepoFromObjective(g); + if (!repo) { + availWrap.replaceChildren(); + const empty = document.createElement('div'); + empty.className = 'goal-picker__empty'; + empty.textContent = t('goalPickerNoIssues'); + availWrap.appendChild(empty); + return; + } + fetchRepoIssues(repo).then((entry) => { + if (!isLiveGoal(g)) return; + availWrap.replaceChildren(); + if (entry.error) { + const err = document.createElement('div'); + err.className = 'goal-picker__empty'; + err.textContent = entry.error; + availWrap.appendChild(err); + return; + } + const avail = entry.issues.filter((ri) => !selectedUrls.has(ri.url)); + if (!avail.length) { + const empty = document.createElement('div'); + empty.className = 'goal-picker__empty'; + empty.textContent = t('issueNoMore'); + availWrap.appendChild(empty); + return; + } + for (const ri of avail) { + availWrap.appendChild(buildCheckRow(g, { url: ri.url, number: ri.number, title: ri.title, status: 'available' }, false)); + } + }); +} + +async function applyIssueSelection(g, btn) { + const adds = [...goalPickerSelection.add]; + const removes = [...goalPickerSelection.remove]; + goalPickerSelection = { goalId: g.goalId, add: new Set(), remove: new Set() }; + if (btn) btn.disabled = true; + const repo = goalRepoFromObjective(g); + const current = (g.issues && g.issues.issues) || []; + for (const url of removes) { + const issue = current.find((i) => i.url === url); + if (!issue || !issue.todoId) continue; + try { + await app.call('loopx.supersedeTodo', { + argvPrefix: S.config.argvPrefix, srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId), goalId: g.goalId, + todoId: issue.todoId, reason: '用户取消修复该 issue', + }); + } catch (_) {} + } + for (const url of adds) { + const m = String(url).match(/\/(?:issues|pull)\/(\d+)/); + if (!m) continue; + try { + await app.call('loopx.addIssueTodo', { + argvPrefix: S.config.argvPrefix, srcDir: S.config.srcDir || null, + projectDir: goalProjectDir(g.goalId), goalId: g.goalId, + text: `Fix GitHub issue #${Number(m[1])} (${url})`, + repo, agentId: g.agentId || null, + }); + } catch (_) {} + } + await refreshGoalIssues(g, true); +} + +function buildGoalPickerDetailContent(g) { + const wrap = document.createElement('div'); + + const head = document.createElement('div'); + head.className = 'goal-picker__detail-head'; + const name = document.createElement('span'); + name.className = 'goal-picker__detail-name'; + name.textContent = goalDisplayName(g); + name.title = g.goalId; + head.append(name, goalStatusChip(g)); + wrap.appendChild(head); + + // 「应用更改」放最上面,勾选后一眼可见。 + const apply = document.createElement('button'); + apply.type = 'button'; + apply.className = 'btn btn--small btn--primary goal-picker__apply'; + apply.textContent = t('issueApply'); + apply.disabled = true; + apply.onclick = () => applyIssueSelection(g, apply); + wrap.appendChild(apply); + + const rows = goalIssueRows(g); + const summary = document.createElement('div'); + summary.className = 'goal-picker__detail-summary'; + if (rows) { + const done = rows.filter((r) => issueRowStatus(g, r) === 'done').length; + summary.textContent = t('issueSummary', rows.length, done); + } else { + summary.replaceChildren(loadingEl(t('issueLoading'))); + } + wrap.appendChild(summary); + + const checklist = document.createElement('div'); + checklist.className = 'goal-picker__checklist'; + wrap.appendChild(checklist); + fillIssueChecklist(g, checklist); + + return wrap; +} + +function renderStatusBar() { + const bar = document.getElementById('status-bar'); + if (!bar) return; + const counts = {}; + for (const k of BOARD_GROUPS) counts[k] = 0; + for (const g of S.goals.values()) { + if (!isOwnedGoal(g.goalId)) continue; + const group = goalGroup(g); + if (counts[group] != null) counts[group] += 1; + } + const total = BOARD_GROUPS.reduce((sum, k) => sum + (counts[k] || 0), 0); + if (total === 0) { + bar.hidden = true; + bar.replaceChildren(); + return; + } + bar.hidden = false; + // The status bar doubles as the board's tab switcher: every group with a + // count is a clickable tab, and the active tab is highlighted. + const frag = document.createDocumentFragment(); + for (const group of BOARD_GROUPS) { + const n = counts[group] || 0; + if (n === 0) continue; + const seg = document.createElement('button'); + seg.type = 'button'; + seg.className = 'status-bar__seg' + + ` status-bar__seg--${group}` + + (S.activeBoardTab === group ? ' is-active' : ''); + if (group === 'decisions' && reviewUnreadCount() > 0) seg.classList.add('status-bar__seg--pulse'); + const label = t(GROUP_I18N_KEY[group] || group); + const b = document.createElement('b'); + b.textContent = String(n); + seg.append(`${label} `, b); + seg.onclick = () => onStatusBarClick(group); + frag.appendChild(seg); + } + bar.replaceChildren(frag); +} + +// 启动后自动选中第一个「需要处理」的目标:review(要你决定)→ running → backlog +// → error → paused。跳过 terminal(done/archived),只做一次,不打扰用户主动关闭。 +const AUTO_SELECT_ORDER = ['decisions', 'notices', 'active', 'backlog', 'error', 'paused']; +function autoSelectGoal(owned) { + for (const group of AUTO_SELECT_ORDER) { + const g = owned.find((x) => goalGroup(x) === group); + if (g) return g; + } + return null; +} + +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 in BOARD_GROUPS: a missing key + // made buckets.get() return undefined and crash the parked spread. + const buckets = new Map(BOARD_GROUPS.map((k) => [k, []])); + for (const g of owned) buckets.get(goalGroup(g)).push(g); + // Keep the active board tab valid before the status bar renders, so the + // highlighted tab and the board content can never disagree. An in-flight + // intake counts as content for the 运行中 tab (its pending row lives there). + const activeTabHasContent = (key) => buckets.get(key).length > 0 + || (key === 'active' && !!S.intakeDraft); + if (!BOARD_GROUPS.includes(S.activeBoardTab) || !activeTabHasContent(S.activeBoardTab)) { + const first = BOARD_GROUPS.find((k) => activeTabHasContent(k)); + S.activeBoardTab = first || 'decisions'; + } + renderStatusBar(); + + // ── The left column is a generic board: the active status tab picks which + // group renders here (需决策 / 知会 / 运行中 / 排队中 / …). ── + const boardGoals = buckets.get(S.activeBoardTab); + document.getElementById('review-zone-title').textContent = t(GROUP_I18N_KEY[S.activeBoardTab] || S.activeBoardTab); + document.getElementById('review-zone-count').textContent = String(boardGoals.length); + document.getElementById('review-zone-sub').textContent = t(GROUP_SUB_KEY[S.activeBoardTab] || ''); + // #7 未读角标 + 脉冲:只在「需决策」Tab 上体现新出现的审批门禁。 + const reviewUnread = S.activeBoardTab === 'decisions' ? boardGoals.filter((rg) => rg.gateUnread).length : 0; + const reviewZone = document.getElementById('review-zone'); + const reviewBadge = document.getElementById('review-zone-badge'); + if (reviewZone) reviewZone.classList.toggle('review-zone--unread', reviewUnread > 0); + if (reviewBadge) { + reviewBadge.hidden = reviewUnread === 0; + reviewBadge.textContent = String(reviewUnread); + } + const reviewList = document.getElementById('review-list'); + reviewList.title = t('kbHint'); + reviewList.replaceChildren(); + // Intake in flight: show the pending "创建中" row at the top of 运行中 so + // the clone/bootstrap stages are visible on the board, not only in the + // composer feedback line. (Its stage text is patched in place by the + // taskIntake progress events.) + if (S.intakeDraft && S.activeBoardTab === 'active') { + reviewList.appendChild(buildIntakeRow(S.intakeDraft)); + } + if (boardGoals.length === 0 && !(S.intakeDraft && S.activeBoardTab === 'active')) { + 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 boardGoals) reviewList.appendChild(buildBoardRow(g)); + + // Other-host goals (unowned, unmonitored until adopted) stay behind a quiet + // chip at the board's foot. Terminal groups are now first-class tabs. + const moreArea = document.getElementById('more-area'); + const moreSig = other.map((goal) => goal.goalId).join(','); + if (force || moreSig !== lastMoreFingerprint) { + moreArea.replaceChildren(); + if (other.length > 0) moreArea.appendChild(buildMoreFooter([{ key: 'other', goals: other }])); + 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 { + if (!S.didAutoSelect && owned.length > 0) { + S.didAutoSelect = true; + const first = autoSelectGoal(owned); + if (first) { + S.activeGoalId = first.goalId; + panel.hidden = false; + emptyHint.hidden = true; + renderGoalDetails(first); + } else { + panel.hidden = true; + emptyHint.hidden = false; + } + } else { + panel.hidden = true; + emptyHint.hidden = false; + } + } + updateEmptyState(); + 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)); + } + 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 running = !!(activeGoal && activeGoal.running); + // A stopped/paused goal (not gated) also keeps the footer bar visible so + // the same slot can host the ▶运行 resume button — no hunt for it elsewhere. + const paused = !!activeGoal && !running + && (activeGoal.userStopped || activeGoal.stopped || !activeGoal.autoRun) + && !isGated(activeGoal); + // A "running" turn that has produced no agent event for a while is almost + // certainly stuck (a hanging tool call, a lost event pipe) — surface it + // instead of pretending "努力解bug中" while the clock quietly climbs. + // EXCEPT while a tool is legitimately executing: tools emit no + // intermediate events, so narrate "工具运行中 · <name> · 已 N 分钟" + // instead of a false "可能卡住了". + const run = running ? agentRuns.get(activeGoal.goalId) : null; + const idleMs = run && run.lastEventAt ? Date.now() - run.lastEventAt : 0; + const inFlight = running ? toolsInFlight(activeGoal) : null; + const runningTool = inFlight && inFlight.size > 0 + ? [...inFlight.values()].reduce((a, b) => (a.startedAt <= b.startedAt ? a : b)) + : null; + const idle = running && idleMs > IDLE_WARN_MS && !runningTool; + const bar = document.getElementById('goal-detail-status'); + const clock = document.getElementById('goal-detail-status-clock'); + const text = bar ? bar.querySelector('.detail-panel__status-text') : null; + const toggle = document.getElementById('btn-detail-toggle-run'); + if (bar) { + bar.hidden = !(running || paused); + bar.classList.toggle('detail-panel__status--paused', !running && paused); + bar.classList.toggle('detail-panel__status--idle', idle); + } + if (text) { + let label = ''; + if (running) { + if (idle) { + label = activeGoal._modelResponded === false ? t('activityModelHang') : t('activityStalled'); + } else if (runningTool && idleMs > 15000) { + // Only switch to the tool narration once the silence is noticeable; + // rapid tool chatter keeps the friendlier default label. + const variant = TOOL_VARIANTS[String(runningTool.name || '').toLowerCase()] || 'others'; + const toolLabel = (TOOL_TITLE[variant] || TOOL_TITLE.others)(); + label = t('activityToolRunning', toolLabel, fmtRunDuration(Date.now() - runningTool.startedAt)); + } else { + label = t('activityDiving'); + } + } else if (paused) { + label = t('statusPaused'); + } + text.textContent = label; + } + if (clock) { + clock.textContent = running + ? (idle + ? `${t('idleFor', fmtRunDuration(idleMs))} · ${t('elapsedLabel', fmtRunDuration(Date.now() - activeGoal.runStartedAt))}` + : fmtRunDuration(Date.now() - activeGoal.runStartedAt)) + : ''; + } + if (toggle) { + if (running) { + toggle.hidden = false; + toggle.textContent = `⏹ ${t('stopShort')}`; + } else if (paused) { + toggle.hidden = false; + toggle.textContent = `▶ ${t('runShort')}`; + } else { + toggle.hidden = true; + } + } + }, 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 a FRESH session +// (stateless resume re-derives the next step from the loopx registry, so a +// hard-cancelled turn loses nothing). 3 minutes keeps silent stalls from +// blocking the queue while still tolerating a genuinely slow first token. +const STALL_TURN_MS = 3 * 60 * 1000; +// While a tool call is in flight, "no events" is EXPECTED: the model wrote +// the call and the tool (pytest, pip install, git clone…) is executing with +// no intermediate events. Killing at 3 minutes was strangling every slow +// tool run and tripping the auto-run breaker on test-heavy repos. Tools have +// their own host-side timeout; this is only the lost-event-pipe failsafe. +const STALL_TOOL_MS = 30 * 60 * 1000; +// A (soft or hard) gate can legitimately keep a turn waiting on a human, but +// the event pipe can also die while gated — a very long failsafe instead of +// skipping the watchdog entirely. +const STALL_GATED_MS = 60 * 60 * 1000; +// Surface a "possibly stalled" warning in the status bar well before the hard +// cancel above: a hanging tool call / lost event pipe must read as "no output +// for N minutes", not a silently climbing elapsed clock. +const IDLE_WARN_MS = 2 * 60 * 1000; + +// In-flight tool calls per goal (toolId -> { name, startedAt }): drives both +// the watchdog exemption above and the "工具运行中" status-bar narration. +function toolsInFlight(g) { + return g._toolsInFlight instanceof Map ? g._toolsInFlight : null; +} +function trackToolEvent(g, te, done) { + const toolId = te && te.tool_id; + if (!toolId) return; + if (!(g._toolsInFlight instanceof Map)) g._toolsInFlight = new Map(); + if (done) { + g._toolsInFlight.delete(toolId); + } else if (!g._toolsInFlight.has(toolId)) { + const name = te.effectiveToolName || te.effective_tool_name + || te.toolName || te.tool_name || ''; + g._toolsInFlight.set(toolId, { name: String(name), startedAt: Date.now() }); + // Bound the map: a lost Completed event must not pin the watchdog + // exemption forever — entries older than the tool stall cap fall off. + for (const [id, info] of g._toolsInFlight) { + if (Date.now() - info.startedAt > STALL_TOOL_MS) g._toolsInFlight.delete(id); + } + } +} +function stallThresholdMs(g) { + if (isGated(g)) return STALL_GATED_MS; + const tools = toolsInFlight(g); + if (tools && tools.size > 0) return STALL_TOOL_MS; + return STALL_TURN_MS; +} + +async function stallRecover(g, run) { + // 先确认是不是「已完成但完成事件丢了」:事件桥偶发丢事件时,turn 其实早已跑完, + // 盲目 cancel 会误杀一个正常完成的 turn,还让 autoFailCount 白白 +1。 + try { + const res = await app.agent.turnText(run.sessionId, run.turnId); + const text = res && typeof res.text === 'string' ? res.text.trim() : ''; + if (text) { + log(`[${g.goalId}] stall recovered: turn actually completed (${text.length} chars)`, true); + finishRun(g, { ok: true }); + return; + } + } catch (_) { + // turnText 失败 = turn 可能仍在跑或已失效;继续走 cancel 分支。 + } + const idleMin = Math.round((Date.now() - run.lastEventAt) / 60000); + // 区分「模型一直没吐第一个字」和「模型已响应但工具/事件管道卡住」:前者几乎 + // 肯定是模型 API 超时/过载,明确写出来比笼统的「运行僵死」更直观。 + const message = g._modelResponded === false + ? t('turnModelHang', idleMin) + : t('turnStalled', idleMin); + log(`[${g.goalId}] ${message}`, true); + // 模型未响应 → 弹非阻塞行动卡 + 系统通知,让用户能一键换模型或继续等待。 + if (g._modelResponded === false) { + g._modelHang = { idleMin, model: modelForGoal(g.goalId) }; + try { + if (app.notifications?.system) { + app.notifications.system(t('notifModelHangTitle'), t('notifModelHangBody', goalDisplayName(g), idleMin)); + } + } catch (_) {} + } + // finishRun(error) already records the message through its error branch — + // recording it here too made the "运行僵死" line appear twice. + 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(); + g._modelResponded = false; + // #4 turn 分组:每次运行开一个新轮次,后续活动日志都打上本轮编号。 + g.turnNumber = (g.turnNumber || 0) + 1; + // 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 = ''; + if (g._toolsInFlight instanceof Map) g._toolsInFlight.clear(); + // Stateless resume (loopx philosophy): every turn starts a FRESH host session + // and re-derives its next step from the loopx registry/todos — the session is + // never reused, so context cannot grow unbounded and stall first-token latency. + recordGoalActivity(g, t('activityStarting'), false, 'waiting'); + // Auto-focus: a task that starts running becomes the selected task UNLESS the + // user is already watching another running task — with multiple repos running + // concurrently, yanking the log panel on every turn start would make the view + // jump around. Only take over when the current view is idle (or empty). + const currentView = S.activeGoalId ? S.goals.get(S.activeGoalId) : null; + if (S.activeGoalId !== g.goalId && (!currentView || !currentView.running)) { + 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; + // 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. The death threshold adapts to what the turn is doing: + // 3m for pure model silence, 30m while a tool call is executing (tools + // emit no intermediate events — pytest/pip/clone runs are silent by + // design), 60m while legitimately gated on a human. Cancel, report, and + // let the poll loop re-decide — auto-run fires the next turn fresh. + if (Date.now() - run.lastEventAt > stallThresholdMs(g)) { + clearInterval(tick); + stallRecover(g, run); + } + }, 10000); + g._runTick = tick; + 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'); + // 用户在 prompt 组装期间中止了任务:直接返回(stopGoalTask 已收尾)。 + if (!g.running) return; + // 启动阶段完成:spinner → ✓。 + resolveWaiting(g, t('activityStartingDone')); + dbgUi('turn:promptReady', `chars=${composed.prompt.length}`); + // 续跑对账:把「已完成(跳过)/ 未完成(继续)」写进日志,让用户一眼看清 + // 现在从哪个 issue 继续。数据来自 loopx 注册表的 todo 状态,非会话记忆。 + if (composed.resumeNote) { + recordGoalActivity(g, `${t('resumeReconcileTitle')}:${composed.resumeNote}`); + } + // 上一轮失败原因注入:新会话不记得上一轮,但失败原因(stall/超时/报错)值得 + // 原样带回去,让模型据此调整方法而不是盲目重试同样的路。 + if (g.lastFailReason) { + composed.prompt += `\n\n上一轮尝试失败的记录(仅作参考,按需调整方法,不要复述给用户):${g.lastFailReason}\n`; + } + // 软门禁下继续推进:某个 issue 的发布审批挂起时,其余 issue 照常处理。把 + // 等待人工审批的 issue 列表注入本轮 prompt,防止 agent 重复触碰待审分支。 + const pendingApprovalUrls = [...new Set((g.userTodos || []) + .filter((td) => gateTodoInfo(td).isBlocking) + .flatMap((td) => parseIssueUrls(String(td.text || td.title || '')).map((r) => r.url)))]; + if (pendingApprovalUrls.length) { + composed.prompt += `\n\n以下 issue 正在等待人工审批,本轮跳过它们(不要改动其分支、不要尝试发布):\n${pendingApprovalUrls.map((u) => `- ${u}`).join('\n')}\n`; + } + // The log shows what was sent to the agent — collapsed, expandable. + recordGoalActivity(g, t('activitySentPrompt', composed.prompt.length), false, 'prompt', composed.prompt); + // 会话上下文很大时,agent.run 到首个事件之间可能长达几分钟(恢复上下文 + 发请求), + // 加一行「等待模型响应」避免误以为卡住。把累计上下文规模和上次实测的首字响应 + // 时长一并写进去,给用户一个「大概要等多久」的心理预期。 + const prevCtx = Number(S.config.agentInputCharsByGoal[g.goalId]) || 0; + const ctxChars = prevCtx + composed.prompt.length; + // Prefer the host-reported inputTokens (real context incl. prior output and + // tool results); the char sum is only a first-turn fallback. + const ctxLabel = contextSizeText(g, ctxChars); + const prevTtft = Number(S.config.agentTtftMsByGoal[g.goalId]) || 0; + const waitLabel = prevTtft > 0 + ? t('activityWaitingModelCtxEta', ctxLabel, fmtRunDuration(prevTtft)) + : t('activityWaitingModelCtx', ctxLabel); + recordGoalActivity(g, waitLabel, false, 'waiting'); + g._waitStartedAt = Date.now(); + g._waitCtxChars = ctxChars; + // Roll the cumulative session-context size forward so the NEXT turn's hint + // reflects the growing context (the dominant factor in first-token latency). + S.config.agentInputCharsByGoal[g.goalId] = ctxChars; + saveConfig(); + // Fresh session every turn: loopx's heartbeat-prompt already carries the + // full next-step (todos/state), so the agent resumes from the registry, not + // from a ballooning chat transcript. No sessionId = a brand-new context. + requestedSessionId = null; + const run = await app.agent.run(composed.prompt, { + sessionName: `bitfun-loopx · ${g.goalId}`, + enableTools: true, + model: modelForGoal(g.goalId), + }); + // 用户在 agent.run 返回句柄前中止了任务:此刻才拿到 session/turn id, + // 立即取消刚启动的 host turn(stopGoalTask 已收尾本地状态)。 + if (!g.running) { + try { await app.agent.cancel(run.sessionId, run.turnId); } catch (_) {} + return; + } + dbgUi('turn:agentStarted', `session=${run.sessionId} turn=${run.turnId} fresh=true`); + 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 }); + } +} + +// Terminal handling shared by completion/failure/cancel events. +// byUser marks an explicit human stop (stopGoalTask); a cancel arriving via +// the event stream is host-initiated (host restart, session pruned) — the +// user never asked for it, so auto-run must survive and retry with backoff +// instead of silently parking the goal forever. +function finishRun(g, { ok, cancelled = false, error = null, byUser = false }) { + if (g._runTick) { clearInterval(g._runTick); g._runTick = null; } + agentRuns.delete(g.goalId); + // Turn over → nothing is in flight any more (a lost Completed event must + // not extend the next turn's watchdog exemption). + if (g._toolsInFlight instanceof Map) g._toolsInFlight.clear(); + // A failed/cancelled turn leaves its "等待模型响应" spinner unresolved — + // land it as ✗ so old turn groups never keep a forever-spinning loader. + if (!ok) resolveWaiting(g, null, true); + // 本轮可能改了 todo 状态:作废 worker 侧的续跑对账缓存,下一轮 prompt 用最新状态。 + try { app.call('loopx.invalidateResumeCache', {}).catch(() => {}); } catch (_) {} + g.running = false; + g.lastRun = { + exitCode: ok ? 0 : 1, + durationMs: g.runStartedAt ? Date.now() - g.runStartedAt : 0, + status: cancelled ? 'cancelled' : (ok ? 'completed' : 'failed'), + ok, + cancelled, + }; + if (cancelled && byUser) { + // 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 (cancelled) { + // Host-initiated cancel (restart, session pruned, stall recovery): the + // user did not ask to stop — treat as a failed turn so auto-run retries + // with the normal backoff instead of parking the goal forever. + g.lastFailReason = error || 'host cancelled the turn'; + if (g.autoRun) { + g.autoFailCount += 1; + g.retryAfter = Date.now() + g.autoFailCount * 60 * 1000; + if (g.autoFailCount >= AUTO_RUN_FAIL_LIMIT) { + g.autoRun = false; + g.retryAfter = 0; + S.config.autoRunByGoal[g.goalId] = false; + saveConfig(); + log(t('autoRunDisabled', g.goalId), true); + } + } + log(`[${g.goalId}] ${t('runCancelledByHost')}`, true); + recordGoalActivity(g, t('runCancelledByHost'), true); + } else if (ok) { + g.autoFailCount = 0; + g.retryAfter = 0; + g.lastFailReason = ''; + g._modelHang = null; + log(`[${g.goalId}] turn completed`); + recordGoalActivity(g, t('activityCompleted')); + } else { + g.lastFailReason = error || ''; + if (g.autoRun) { + g.autoFailCount += 1; + // 渐进冷却:第 N 次失败后等 N 分钟再重试,避免对瞬时抖动狂打。 + g.retryAfter = Date.now() + g.autoFailCount * 60 * 1000; + 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; + g.retryAfter = 0; + 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 + // A finished turn frees an auto-run slot: goals that were capped out by + // MAX_CONCURRENT_AUTO_RUNS get their shot now instead of waiting a full + // poll interval. + for (const other of S.goals.values()) { + if (other !== g) maybeAutoRun(other); + } +} + +function goalForAgentSession(sessionId) { + for (const [goalId, run] of agentRuns) { + if (run.sessionId === sessionId) return S.goals.get(goalId); + } + return null; +} + +// Reverse sessionId → goalId lookup (persisted + in-memory), used by the +// token-usage handler which may fire after the run entry is already cleared. +function goalIdForSessionId(sessionId) { + for (const [goalId, sid] of S.agentSessionByGoal) { + if (sid === sessionId) return goalId; + } + for (const [goalId, sid] of Object.entries(S.config.agentSessionByGoal || {})) { + if (sid === sessionId) return 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] = ''; + // A status/tool line ends the current stream block (upsertGoalStream walks + // back only past ticks). If the last non-tick entry is no longer our stream + // block, the next upsert CREATES a new block — it must contain only the new + // text, not the whole turn's accumulated buffer (which would repaint + // everything already shown as a duplicate). + const kind = think ? 'think' : 'agent'; + let interrupted = false; + if (g[key] && Array.isArray(g.activityLines)) { + for (let i = g.activityLines.length - 1; i >= 0; i -= 1) { + const e = g.activityLines[i]; + if (e.isTick) continue; + interrupted = !(e.kind === kind && e.stream); + break; + } + } + if (interrupted) 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); + const agentVal = pend && typeof pend.agent === 'string' ? pend.agent : g.agentTextBuffer; + const thinkVal = pend && typeof pend.think === 'string' ? pend.think : g.thinkBuffer; + const agentTrim = String(agentVal || '').trim(); + const thinkTrim = String(thinkVal || '').trim(); + // Reasoning models (deepseek etc.) put the real analysis in reasoning_content + // and emit a near-empty visible reply (a few newlines/punctuation). When the + // visible reply is that thin, promote a bounded tail of the reasoning as the + // visible conclusion so the log still shows WHAT the agent decided — instead + // of burying it in a collapsed "思考过程" block. + let promoted = null; + const meaningful = agentTrim.replace(/[\s\p{P}\p{S}]/gu, ''); + if (meaningful.length < 8 && thinkTrim) { + promoted = latestLineOf(thinkTrim); + if (promoted.length > 360) promoted = promoted.slice(-360); + } + // Flush think first (mirrors the streaming order), so the agent upsert below + // can still find and extend the newest think block instead of duplicating it. + if (thinkTrim) upsertGoalStream(g, 'think', thinkTrim); + if (agentTrim) upsertGoalStream(g, 'agent', agentTrim); + else if (promoted) upsertGoalStream(g, 'agent', promoted); + g.agentTextBuffer = ''; + g.thinkBuffer = ''; + if (pend) { pend.think = null; pend.agent = null; } +} + +// 本轮结论:从最终可见文本(reasoning 模型几乎不吐正文时退回推理尾行)里 +// 取一段单行收尾。只做展示,不参与后续决策。 +function turnConclusion(g) { + let src = String(g.agentTextBuffer || '').trim(); + const meaningful = src.replace(/[\s\p{P}\p{S}]/gu, ''); + if (meaningful.length < 8) { + const think = String(g.thinkBuffer || '').trim(); + if (think) src = latestLineOf(think); + } + src = String(src || '').replace(/\s+/g, ' ').trim(); + if (!src) return ''; + if (src.length > 240) src = src.slice(-240); + return src; +} + +// 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) { + // Trim BOTH ends: a model that opens its reply with a newline (very common) + // would otherwise push the first visible line down one row under the + // timestamp (pre-wrap renders the leading blank line), misaligning it. + const summary = String(text || '').trim(); + 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; + } + const preview = row.querySelector('.activity-prompt__think-preview'); + if (preview) preview.textContent = latestLineOf(cappedStreamText(summary, STREAM_DOM_CAPS.think)); + } 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) { + const row = lastActivityRow(stream); + const follow = streamAtTail(stream); + if (row) patchRow(row); + if (follow) streamFollowTail(stream); + } + } else { + const entry = { time: now, line: summary, isErr: false, count: 1, kind, stream: true, turn: g.turnNumber || 0 }; + 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(); + appendActivityEntry(stream, entry); + 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 rawText; + 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 buf; +} + +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); + } +} + +// ── 工具行模型(参考 DSH 的 toolRowModel)────────────────── +// 把宿主工具按类别归一,显示「类别标题 · 有意义的参数」,而不是原始命令前缀 +// ("python -u -c" 这种)。shell 工具优先用模型自己写的 description,其次才提炼命令。 +const TOOL_VARIANTS = { + execcommand: 'shell', bash: 'shell', sh: 'shell', pwsh: 'shell', powershell: 'shell', cmd: 'shell', + read: 'read', ls: 'read', list: 'read', glob: 'read', cat: 'read', web_fetch: 'read', webfetch: 'read', + grep: 'search', search: 'search', web_search: 'search', websearch: 'search', find: 'search', + write: 'write', edit: 'edit', patch: 'edit', + run_code: 'code', runcode: 'code', +}; +const TOOL_TITLE = { + shell: () => t('toolShell'), read: () => t('toolRead'), search: () => t('toolSearch'), + write: () => t('toolWrite'), edit: () => t('toolEdit'), code: () => t('toolCode'), others: () => t('toolCall'), +}; + +// 命令主体提炼:loopx / git / 包管理器子命令优先;python -u -c 提取 import/调用片段。 +function smartCommand(cmd) { + let c = String(cmd || '').trim().replace(/\s+/g, ' '); + if (!c) return ''; + // 剥掉前置的环境变量赋值("$env:PYTHONIOENCODING=utf-8;"、"set X=y"、"export X=y") + // 和 cd/pushd 这类纯导航噪声 —— 它们本身没有信息量,后面的真实命令才是主体。 + c = c + .replace(/^\s*(?:(?:\$env:|set\s+|export\s+)[A-Za-z_][A-Za-z0-9_]*=[^;]*;?\s*)+/i, '') + .replace(/^\s*(?:cd|pushd|set-location)\s+[^;&|]+[;&|]?\s*/i, '') + .trim(); + if (!c) return ''; + let m = c.match(/loopx(?:\.cli)?\s+(?:--format\s+\S+\s+)?([a-z][a-z-]*)/i); + if (m) return `loopx ${m[1]}`; + m = c.match(/\bgit\s+([a-z][a-z-]*)\b/i); + if (m) return `git ${m[1]}`; + // python -m <module> [args]:模块名才是信息主体;"-m" 单独匹配会丢掉它。 + // "python -m pip install x" → "pip install x";"python -m pytest" → "pytest"。 + m = c.match(/\bpython3?\s+-m\s+([A-Za-z0-9_.-]+)(?:\s+([^;&|]{0,60}))?/i); + if (m) { + const mod = m[1]; + const rest = (m[2] || '').trim(); + return rest ? `${mod} ${rest}` : mod; + } + // python -u -c one-liner first: the code fragment is the informative part. + m = c.match(/\bpython\s+(?:-u\s+)?-c\s+["']?([^"']{0,70})/i); + if (m) return `python -c ${m[1].trim().replace(/\s+/g, ' ')}`; + // Package managers / interpreters: capture the next token (subcommand or flag). + m = c.match(/\b(pip3?|python3?|npm|pnpm|yarn|cargo|node|npx)\s+([^\s"']+)/i); + if (m) return `${m[1]} ${m[2]}`; + // here-doc / here-string 灌给解释器:内容对人是噪音,只报目标解释器。 + m = c.match(/(@'|@"|<<-?['"]?[A-Za-z_]*['"]?)\s*\|\s*(python3?|node|pwsh|powershell)/i); + if (m && m[2]) return `${m[2]} (heredoc)`; + const first = c.split(/[;&|]|\r?\n/)[0].trim().replace(/"/g, ''); + return first.length > 90 ? `${first.slice(0, 87)}…` : first; +} + +// 文件路径缩短:优先相对于项目根,其次退回「…/末两段」。绝对路径(尤其 +// .codex/goals/.../ACTIVE_GOAL_STATE.md 这种)整段上屏只会换行且无信息量。 +function shortPath(p, projectDir) { + const raw = String(p || '').trim(); + if (!raw) return raw; + const norm = raw.replace(/\\/g, '/'); + const dir = projectDir ? String(projectDir).replace(/\\/g, '/').replace(/\/+$/, '') : ''; + if (dir) { + const dirLower = dir.toLowerCase(); + const idx = norm.toLowerCase().indexOf(dirLower); + if (idx === 0) { + const rel = norm.slice(dir.length).replace(/^\/+/, ''); + if (rel) return rel; + } + } + const parts = norm.split('/'); + return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : norm; +} + +// 关键动作:克隆/提交/推送、装依赖、跑测试、提 PR、写证据 —— 这些要高亮。 +const KEY_COMMAND_RE = /(git\s+(clone|commit|push|checkout)|pip(3)?\s+install|npm\s+(install|ci)|cargo\s+(build|test)|pytest|loopx\s+(evidence-log|todo\s+(add|complete)|publish)|pr\s+create|create\s+pr)/i; +function isKeyCommand(cmd) { + return KEY_COMMAND_RE.test(String(cmd || '')); +} + +function toolRowModel(name, brief, projectDir) { + const variant = TOOL_VARIANTS[String(name || '').toLowerCase()] || 'others'; + const title = (TOOL_TITLE[variant] || TOOL_TITLE.others)(); + const rawText = String(brief || '').trim(); + let args = null; + let argv = null; + try { + const p = JSON.parse(rawText); + if (Array.isArray(p)) argv = p.map(String).join(' '); + else if (p && typeof p === 'object') args = p; + } catch (_) {} + let summary = ''; + let key = false; + if (args) { + if (variant === 'shell') { + const desc = String(args.description || '').trim(); + const cmd = String(args.command || args.cmd || ''); + summary = desc || smartCommand(cmd); + key = isKeyCommand(cmd); + } else if (variant === 'read' || variant === 'write' || variant === 'edit') { + summary = shortPath(String(args.file_path || args.filePath || args.path || '').trim(), projectDir); + key = variant !== 'read'; + } else if (variant === 'search') { + const q = args.query || args.pattern || args.queries; + summary = typeof q === 'string' ? q : String(q || ''); + } else if (variant === 'code') { + summary = String(args.description || '').trim() || String(args.code || '').trim(); + key = true; + } else { + for (const v of Object.values(args)) { + if (typeof v === 'string' && v) { summary = v; break; } + } + } + } else if (argv) { + summary = smartCommand(argv); + } else if (variant === 'shell') { + // Partial JSON (mid-stream): best-effort extract the description/command. + let m = rawText.match(/"(?:description)"\s*:\s*"([^"]*)/); + if (m && m[1]) summary = m[1].replace(/\\(.)/g, '$1'); + else { + m = rawText.match(/"(?:command|cmd)"\s*:\s*"([^"]*)/); + if (m && m[1]) summary = smartCommand(m[1].replace(/\\(.)/g, '$1')); + } + } + if (!summary) summary = rawText.split(/[\r\n]/)[0].slice(0, 90); + return { variant, title, summary, key }; +} + +function toolLine(name, brief, projectDir) { + const row = toolRowModel(name, brief, projectDir); + return { text: row.summary ? `${row.title} · ${row.summary}` : row.title, key: row.key }; +} + +app.agent.onEvent((e) => { + // Authoritative context size: the host reports inputTokens/maxContextTokens + // after a turn. Remember it per goal so the NEXT "等待模型响应" hint shows + // the real session context (prompts + prior output + tool results) instead + // of only the prompt characters we sent. Subagent usage is ignored. + if (String(e.sourceEvent || '').endsWith('token-usage-updated')) { + let g = goalForAgentSession(e.sessionId); + if (!g) { + const gid = goalIdForSessionId(e.sessionId); + g = gid ? S.goals.get(gid) : null; + } + if (g && !e.isSubagent) { + const it = Number(e.inputTokens) || 0; + const max = Number(e.maxContextTokens) || 0; + if (it > 0) { + S.config.agentInputTokensByGoal[g.goalId] = it; + if (max > 0) S.config.agentMaxContextTokensByGoal[g.goalId] = max; + saveConfig(); + } + } + // Token usage IS liveness: the session produced measurable work. Feed the + // stall watchdog so a turn mid-generation is never judged dead. + if (g) { + const liveRun = agentRuns.get(g.goalId); + if (liveRun) liveRun.lastEventAt = Date.now(); + } + return; + } + // Diagnostic: the activity stream was missing the model's visible text and + // reasoning (0 agent/think lines). Log every text-chunk here so the next + // run shows exactly which events reach the MiniApp iframe and where the + // pipeline drops them. + if (e.sourceEvent === 'text-chunk') { + dbgUi('agent:text', `len=${typeof e.text === 'string' ? e.text.length : 0} think=${e.contentType === 'thinking'}`); + } + // 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); + const summaryText = cleanGateSummary(String(summaryRun.buffer || '')) || ''; + // Remember for the whole session regardless of goal-object churn, so a + // re-render never re-runs this model call. + summaryDoneSession.set(`${summaryRun.goalId}\u0000${summaryRun.todoId}`, summaryText); + 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: summaryText, + }); + 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); + // Failed run: mark done-with-empty this session too, so it does not + // re-trigger on every render; the card falls back to the pattern hints. + summaryDoneSession.set(`${summaryRun.goalId}\u0000${summaryRun.todoId}`, ''); + 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') { + markModelResponded(g); + // 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'; + // In-flight tracking feeds the stall watchdog (long tool runs are silent + // by design) and the status-bar "工具运行中" narration. + trackToolEvent(g, te, done); + 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; + // brief is the raw params JSON; toolBriefFromText returns '' while the + // buffer is still a partial object, so wait for a parseable payload. + if (!toolBriefFromText(brief, done) && !done) return; + toolLinesRecorded.set(te.tool_id, true); + pruneToolMaps(); + const line = toolLine(name, brief, goalProjectDir(g.goalId)); + recordGoalActivity(g, line.text, false, 'tool', null, line.key); + } else { + const line = toolLine(name, brief, goalProjectDir(g.goalId)); + recordGoalActivity(g, line.text, false, 'tool', null, line.key); + } + } + } else if (e.sourceEvent === 'text-chunk') { + markModelResponded(g); + // 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') { + const conclusion = turnConclusion(g); + flushAgentText(g); + if (conclusion) recordGoalActivity(g, `${t('turnConclusionLabel')}${conclusion}`, false, 'conclusion'); + 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-github').addEventListener('click', openTokenDialog); +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); + +// ── 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}` : ''}`; +} + +// Restart-recovery banner: "上次有 N 个任务在运行 — [全部继续]". Rendered into +// the composer feedback slot with an action button; dismissed on resume or ✕. +function showBootResumeBanner(goalIds) { + const feedback = document.getElementById('task-feedback'); + if (!feedback) return; + feedback.replaceChildren(); + feedback.hidden = false; + feedback.className = 'composer__feedback composer__feedback--ok'; + const label = document.createElement('span'); + label.textContent = t('bootPausedBanner', goalIds.length); + const resumeBtn = document.createElement('button'); + resumeBtn.type = 'button'; + resumeBtn.className = 'btn btn--tiny btn--primary'; + resumeBtn.textContent = t('bootPausedResumeAll'); + resumeBtn.onclick = () => { + let resumed = 0; + for (const goalId of goalIds) { + const g = S.goals.get(goalId); + if (!g || g.archived || isTerminal(g) || g.userStopped) continue; + setAutoRun(g, true); + pollNow(g, { force: true }); + resumed += 1; + } + S.bootPausedGoalIds = null; + setTaskFeedback(t('bootPausedResumed', resumed), 'ok'); + renderAllGoals(true); + }; + const dismissBtn = document.createElement('button'); + dismissBtn.type = 'button'; + dismissBtn.className = 'btn btn--tiny'; + dismissBtn.textContent = t('bootPausedDismiss'); + dismissBtn.onclick = () => { + S.bootPausedGoalIds = null; + setTaskFeedback(''); + }; + feedback.append(label, resumeBtn, dismissBtn); +} + +// ── #5 空状态 / 上手引导 ─────────────────────────────────── +function updateEmptyState() { + const onboard = document.getElementById('onboard'); + const hint = document.getElementById('detail-empty-hint'); + const icon = document.querySelector('#detail-empty > svg'); + const empty = !S.bootLoading && S.goals.size === 0; + if (onboard) { + onboard.hidden = !empty; + if (empty) { + document.getElementById('onboard-title').textContent = t('emptyBoardTitle'); + document.getElementById('onboard-hint').textContent = t('emptyBoardHint'); + document.getElementById('onboard-sample-issue').textContent = t('emptySampleIssue'); + document.getElementById('onboard-sample-repo').textContent = t('emptySampleRepo'); + } + } + if (hint) hint.hidden = empty; + if (icon) icon.style.display = empty ? 'none' : ''; +} + +function updateTaskKind() { + const badge = document.getElementById('task-kind'); + if (S.composerMode === 'guide') { + badge.hidden = true; + updateVisionHint(); + return; + } + const input = document.getElementById('composer-link-input'); + const kind = input ? taskInputKind(input.value) : ''; + badge.textContent = kind; + badge.hidden = !kind; + updateVisionHint(); +} + +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; + const link = document.getElementById('composer-link-input'); + if (link) link.disabled = busy; + document.getElementById('btn-create-task').disabled = busy; + setTaskFeedback(message, busy ? '' : undefined); +} + +// Compact relative age for an issue's updated_at so the intake list can show +// recency without a full locale-aware date formatter. +function issueAgeLabel(iso) { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return ''; + const minutes = Math.floor((Date.now() - then) / 60000); + if (minutes < 1) return t('issueAgeNow'); + if (minutes < 60) return t('issueAgeMin', minutes); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t('issueAgeHour', hours); + const days = Math.floor(hours / 24); + if (days < 30) return t('issueAgeDay', days); + return t('issueAgeMonth', Math.floor(days / 30)); +} + +// 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 body = document.createElement('span'); + body.className = 'intake-issue__body'; + const title = document.createElement('span'); + title.className = 'intake-issue__title'; + title.textContent = issue.title === `#${issue.number}` ? issue.url : issue.title; + title.title = issue.url; + body.appendChild(title); + // Metadata line: type labels + comment count + recency, so the user can + // triage issues at a glance instead of reading every title in full. + const meta = document.createElement('span'); + meta.className = 'intake-issue__meta'; + if (Array.isArray(issue.labels) && issue.labels.length) { + for (const label of issue.labels.slice(0, 3)) { + const chip = document.createElement('span'); + chip.className = 'issue-label'; + chip.textContent = String(label); + meta.appendChild(chip); + } + } + if (typeof issue.comments === 'number' && issue.comments > 0) { + const comments = document.createElement('span'); + comments.className = 'intake-issue__comments'; + comments.textContent = t('issueComments', issue.comments); + meta.appendChild(comments); + } + if (issue.updatedAt) { + const age = document.createElement('span'); + age.className = 'intake-issue__age'; + age.textContent = issueAgeLabel(issue.updatedAt); + meta.appendChild(age); + } + // 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'); + meta.appendChild(badge); + } + // Already-resolved upstream: flagged here so the user sees it BEFORE + // confirming — but it stays a notice, not a gate (unchecking is still + // allowed, though re-checking it would be skipped again at intake). + if (issue.closed) { + const badge = document.createElement('span'); + badge.className = 'intake-issue__resolved'; + badge.textContent = t('issueResolvedBadge'); + badge.title = t('issueResolvedBadgeTitle', issue.stateReason || issue.state || 'closed'); + meta.appendChild(badge); + row.classList.add('intake-issue--resolved'); + } + if (meta.childElementCount) body.appendChild(meta); + row.append(cb, num, body); + 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; + } + // Non-blocking notice: issues already closed upstream will be skipped at + // intake — inform, don't gate. (A user may still uncheck them; the intake + // loop re-checks live status and skips again regardless.) + const resolvedCount = resolved.issues.filter((issue) => issue.closed).length; + const resolvedWarn = document.getElementById('intake-resolved-warn'); + if (resolvedCount > 0) { + resolvedWarn.hidden = false; + resolvedWarn.textContent = t('intakeResolvedWarn', resolvedCount); + } else { + resolvedWarn.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') }; + // 待创建行渲染在「运行中」Tab 顶部:切过去让用户看到 clone/创建进度。 + S.activeBoardTab = 'active'; + 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')); + hideIntakeStepper(); + 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, ''); + hideIntakeStepper(); + setTaskFeedback(String(err.message || err), 'error'); + log(`task intake error: ${err.message || err}`, true); + }); +} + +// ── intake 阶段流水线(横向 stepper)──────────────────────── +// 任务创建是固定流程:获取列表 → 克隆 → 创建任务 → 注册 Agent → 写 todos → +// 刷新。全列出来让用户看到总体进度;plan 与 todos 互斥,共用一个位置。 +const INTAKE_STAGE_SEQ = [ + { key: 'expand', label: () => t('stageExpand') }, + { key: 'clone', label: () => t('stageClone') }, + { key: 'bootstrap', label: () => t('stageBootstrap') }, + { key: 'register', label: () => t('stageRegister') }, + { key: 'todos', label: () => t('stageWriteTodos') }, + { key: 'refresh', label: () => t('stageRefresh') }, +]; +const INTAKE_STAGE_IDX = { expand: 0, clone: 1, bootstrap: 2, register: 3, plan: 4, todos: 4, refresh: 5 }; + +function renderIntakeStepper(currentKey) { + const el = document.getElementById('intake-stepper'); + if (!el) return; + const idx = INTAKE_STAGE_IDX[currentKey]; + if (idx == null) { el.hidden = true; return; } + el.hidden = false; + el.replaceChildren(); + INTAKE_STAGE_SEQ.forEach((s, i) => { + if (i > 0) { + const sep = document.createElement('span'); + sep.className = 'intake-stepper__sep'; + sep.textContent = '›'; + el.appendChild(sep); + } + const seg = document.createElement('span'); + seg.className = 'intake-stepper__seg' + + (i < idx ? ' is-done' : '') + + (i === idx ? ' is-active' : ''); + const mark = document.createElement('span'); + mark.className = 'intake-stepper__mark'; + mark.textContent = i < idx ? '✓' : String(i + 1); + const label = document.createElement('span'); + label.className = 'intake-stepper__label'; + label.textContent = s.label(); + seg.append(mark, label); + el.appendChild(seg); + }); +} +function hideIntakeStepper() { + const el = document.getElementById('intake-stepper'); + if (el) el.hidden = 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'), + resolved: t('stageResolved'), + }; + S.intakeDraft.stage = stages[d.stage] || t('taskCreating'); + setTaskFeedback(S.intakeDraft.stage); + renderIntakeStepper(d.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) => { + hideIntakeStepper(); + 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)}` : ''; + // Issues already resolved upstream are NOT errors: skip silently re-fixing + // them and surface a non-blocking notice (composer note + activity log), + // never a blocking gate. + const resolvedItems = Array.isArray(result.skippedResolvedIssues) ? result.skippedResolvedIssues : []; + const resolvedNote = result.skippedResolved > 0 ? ` ${t('skippedResolved', result.skippedResolved)}` : ''; + const resolvedReason = (it) => it.stateReason || it.state || 'closed'; + for (const it of resolvedItems) { + log(t('issueResolvedLog', it.number, resolvedReason(it))); + } + if (!result.ok) { + // Goal exists but some todos failed — adopt it, say so honestly. + setTaskFeedback(t('taskPartial', resultName, result.writtenOk ?? 0, result.error || '') + skipNote + resolvedNote, 'error'); + log(`[${result.goalId}] task intake partial: ${result.error}`, true); + } else if (result.mode === 'guide') { + setTaskFeedback(t('guideStarted', resultName) + skipNote + resolvedNote, 'ok'); + log(`[${result.goalId}] guidance written (${result.written.length} todos)`); + } else { + setTaskFeedback(t('taskStarted', resultName) + skipNote + resolvedNote, '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; + } + // Non-blocking notice: an issue already closed upstream was skipped at + // intake, so the agent never re-fixes it. Surface it in the goal's own + // activity log — informational, never a review-column decision. + for (const it of resolvedItems) { + recordGoalActivity(goal, t('issueResolvedLog', it.number, resolvedReason(it))); + } + // Memory wiring outcome, also informational (never a gate): whether the + // OpenViking repository/reward memory actually took effect for this goal. + if (result.rewardMemory && result.rewardMemory.ok) { + recordGoalActivity(goal, t('rewardMemoryOn')); + } else if (result.rewardMemory) { + recordGoalActivity(goal, t('rewardMemoryOff', result.rewardMemory.error || result.rewardMemory.reason || 'unknown'), true); + } + if (result.repoMemorySync && result.repoMemorySync.pending) { + recordGoalActivity(goal, t('repoMemorySyncStarted')); + } else if (result.repoMemorySync && result.repoMemorySync.ok) { + recordGoalActivity(goal, t('repoMemorySyncOn', result.repoMemorySync.scopeRef || '?')); + } else if (result.repoMemorySync) { + recordGoalActivity(goal, t('repoMemorySyncOff', result.repoMemorySync.error || result.repoMemorySync.reason || 'unknown'), true); + } + // 联动:新建/并入任务后,日志面板立即切到这个目标(不等待首轮启动)。 + openGoalDetails(goal); + // 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); + } +}); + +// 后台仓库记忆索引完成/失败:把结果补记进对应任务的活动日志(intake 已不再 +// 阻塞等它)。失败是非致命的——记忆缺席只是降级,不影响修复流程。 +app.on('worker:repoMemorySync:done', (d) => { + if (!d || !d.goalId) return; + const goal = S.goals.get(d.goalId); + if (!goal || !isLiveGoal(goal)) return; + if (d.ok) recordGoalActivity(goal, t('repoMemorySyncOn', d.scopeRef || '?')); + else recordGoalActivity(goal, t('repoMemorySyncOff', d.error || d.reason || 'unknown'), true); +}); + +async function createTaskFromInput() { + dbgUi('createTask:start', `mode=${S.composerMode}`); + + // 引导 mode: the textarea holds the guidance message; route to startGuidance. + if (S.composerMode === 'guide') { + const input = document.getElementById('task-input'); + const text = (input.value || '').trim(); + if (!text) { input.focus(); 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, text); + return; + } + + // 新建 mode: link + optional note → issue intake (always a new task). + const objective = composerObjective(); + if (!objective) { + const link = document.getElementById('composer-link-input'); + if (link) link.focus(); + return; + } + if (!taskInputKind(objective)) { + const bad = firstUnsupportedGithubUrl(objective); + if (bad) { + dbgUi('createTask:unsupported', bad); + setTaskFeedback(t('taskUnsupportedPath', bad), 'error'); + return; + } + setTaskFeedback(t('taskNeedLink'), 'error'); + 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.) + // Single-issue submissions go through the SAME confirmation sheet as + // batches: the sheet is the user's documented consent to fix (and edit) + // this repository — refreshUserTodos later cites it to auto-approve the + // write-access gate, so skipping it would auto-approve on consent that was + // never actually shown. + if (resolved.issues.length === 0) { + startTaskIntake({ resolved, objective, selected: new Set() }, 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}`); + // 联动:把日志面板切到被纠正的目标,让用户立刻看到指令落在哪里。 + if (S.activeGoalId !== g.goalId) { + S.activeGoalId = g.goalId; + document.getElementById('goal-detail-panel').hidden = false; + document.getElementById('detail-empty').hidden = true; + renderGoalDetails(g); + } + 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); + +// 双态 composer:模式 tab 切换 + 链接输入实时 unfurl。 +document.getElementById('mode-new').addEventListener('click', () => setComposerMode('new')); +document.getElementById('mode-guide').addEventListener('click', () => setComposerMode('guide')); +const linkInputEl = document.getElementById('composer-link-input'); +if (linkInputEl) { + linkInputEl.addEventListener('input', () => { + renderLinkUnfurl(); + updateTaskKind(); + setTaskFeedback(''); + }); + linkInputEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || event.shiftKey) return; + event.preventDefault(); + createTaskFromInput(); + }); +} + +// #5 空状态:样例模板。占位符 <owner>/<repo> 无法直接提交(会解析失败), +// 用 placeholder 展示格式、把光标留在空输入框里让用户直接粘贴真实链接。 +const sampleFill = (placeholder) => { + setComposerMode('new'); + const input = document.getElementById('composer-link-input'); + if (input) { + input.value = ''; + input.placeholder = placeholder; + input.focus(); + } + renderLinkUnfurl(); + updateTaskKind(); +}; +document.getElementById('onboard-sample-issue').addEventListener('click', () => { + sampleFill('https://github.com/owner/repo/issues/123'); +}); +document.getElementById('onboard-sample-repo').addEventListener('click', () => { + sampleFill('https://github.com/owner/repo'); +}); + +// ── #6 键盘筛选/审批 ────────────────────────────────────── +// review 列是决策队列:j/k 移动、Enter/x 打开详情、a 打开首个审批。 +function moveReviewFocus(cards, delta) { + const idx = cards.findIndex((c) => c === document.activeElement); + const next = idx < 0 ? 0 : (idx + delta + cards.length) % cards.length; + cards[next].focus(); + cards[next].scrollIntoView({ block: 'nearest' }); +} +document.addEventListener('keydown', (e) => { + if (document.querySelector('dialog[open]')) return; + const tag = (document.activeElement && document.activeElement.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (e.altKey || e.ctrlKey || e.metaKey) return; + const reviewCards = [...document.querySelectorAll('#review-list .board-row[tabindex="0"]')]; + if (!reviewCards.length) return; + const key = e.key.toLowerCase(); + if (key === 'j' || key === 'arrowdown') { + e.preventDefault(); + moveReviewFocus(reviewCards, 1); + } else if (key === 'k' || key === 'arrowup') { + e.preventDefault(); + moveReviewFocus(reviewCards, -1); + } else if (key === 'enter' || key === 'x') { + const target = reviewCards.find((c) => c === document.activeElement) || reviewCards[0]; + if (target) { e.preventDefault(); target.click(); } + } else if (key === 'a') { + const target = reviewCards.find((c) => c === document.activeElement) || reviewCards[0]; + const approveBtn = target && target.querySelector('.btn--approve'); + if (approveBtn) { e.preventDefault(); approveBtn.click(); } + } +}); +document.getElementById('composer-model').addEventListener('change', async () => { + S.config.defaultModel = document.getElementById('composer-model').value || 'auto'; + await saveConfig(); + log(t('modelChanged', S.config.defaultModel)); + updateVisionHint(); +}); +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(); +}); +// The old #D in-progress goal dropdown was folded into the board + composer +// tabs; its DOM element is gone, so no listeners are wired here. + + +// 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'); + if (review && S.config.reviewZoneWidth > 0) { + review.style.flex = `0 0 ${S.config.reviewZoneWidth}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' }, +); + +// ── 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); + +// ── 环境引导页(Setup Gate)─────────────────────────────── +// 首次使用 / 环境缺依赖时,用引导页替代任务输入框:一键装好并配置,再放行到 +// 主界面。检测通过则不打扰(直接进 composer)。 +const ENV_ITEM_LABEL = { + python: () => t('envItemPython'), + git: () => t('envItemGit'), + loopx: () => t('envItemLoopx'), + openviking: () => t('envItemOpenViking'), + openvikingRunning: () => t('envItemOpenVikingServer'), + openvikingCli: () => t('envItemOvCli'), + gh: () => t('envItemGh'), +}; +const ENV_ITEM_WHY = { + python: { zh: 'loopx / OpenViking 的运行时', en: 'Runtime for loopx / OpenViking' }, + git: { zh: '克隆目标仓库', en: 'Clone target repos' }, + loopx: { zh: '修 issue 引擎', en: 'The issue-fix engine' }, + openviking: { zh: '仓库记忆 / 语义检索', en: 'Repo memory / semantic search' }, + openvikingRunning: { zh: '上下文数据库服务', en: 'Context database service' }, + openvikingCli: { zh: '记忆读写通道(ov CLI 连接)', en: 'Memory read/write channel (ov CLI)' }, + gh: { zh: '发布 PR / 解除 API 限流', en: 'Publish PRs / lift API rate limit' }, +}; +// Manual install pointers for deps the one-click flow cannot install itself +// (python/git are ITS prerequisites). Opened via the system browser. +const ENV_ITEM_INSTALL = { + python: { + url: 'https://www.python.org/downloads/', + cmd: () => (navigator.platform || '').startsWith('Win') ? 'winget install Python.Python.3.12' : 'brew install python', + }, + git: { + url: 'https://git-scm.com/downloads', + cmd: () => (navigator.platform || '').startsWith('Win') ? 'winget install Git.Git' : 'brew install git', + }, +}; +function envWhy(key) { + const locale = app.locale === 'en-US' ? 'en' : 'zh'; + return (ENV_ITEM_WHY[key] || {})[locale] || ''; +} + +function envOnboardShowState(name) { + for (const key of ['loading', 'guide', 'progress', 'done']) { + const el = document.getElementById(`env-state-${key}`); + if (el) el.hidden = key !== name; + } +} + +function envOnboardSetVisible(visible) { + const overlay = document.getElementById('env-onboard'); + if (overlay) overlay.hidden = !visible; +} + +function renderEnvChecklist(items) { + const list = document.getElementById('env-onboard-list'); + if (!list) return; + list.replaceChildren(); + const rows = [ + ['python', items.python], + ['git', items.git], + ['loopx', items.loopx], + ['openviking', items.openviking], + ['openvikingRunning', items.openvikingRunning], + ['openvikingCli', items.openvikingCli], + ['gh', items.gh], + ]; + for (const [key, item] of rows) { + // "OpenViking 服务" 需要二进制存在且进程正在监听端口,二者都就绪才算通过。 + const ok = key === 'openvikingRunning' + ? !!(item && item.ok && items.openvikingServer && items.openvikingServer.ok) + : !!(item && item.ok); + const li = document.createElement('li'); + li.className = `env-onboard__item ${ok ? 'env-onboard__item--ok' : 'env-onboard__item--miss'}`; + const dot = document.createElement('span'); + dot.className = 'env-onboard__item-dot'; + const label = document.createElement('span'); + label.className = 'env-onboard__item-label'; + const name = document.createElement('span'); + name.textContent = (ENV_ITEM_LABEL[key] && ENV_ITEM_LABEL[key]()) || key; + const why = document.createElement('span'); + why.className = 'env-onboard__item-why'; + why.textContent = ok ? (item.version || envWhy(key)) : `缺失 · ${envWhy(key)}`; + label.append(name, document.createElement('br'), why); + li.append(dot, label); + if (key === 'gh' && !ok) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn--tiny btn--primary'; + btn.textContent = t('ghLoginBtn'); + btn.onclick = () => runGhLoginFromOnboard(btn); + li.appendChild(btn); + } + // python/git 是「一键安装」自身的前置:装不了它们自己,必须给出可执行的 + // 出口(官网下载 + 平台安装命令),而不是一行「缺失」死文案。 + if (ENV_ITEM_INSTALL[key] && !ok) { + const info = ENV_ITEM_INSTALL[key]; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn--tiny btn--primary'; + btn.textContent = t('envInstallLinkBtn'); + btn.onclick = () => openExternalUrl(info.url); + li.appendChild(btn); + const cmdHint = document.createElement('code'); + cmdHint.className = 'env-onboard__item-cmd'; + cmdHint.textContent = info.cmd(); + cmdHint.title = t('envInstallCmdHint'); + li.appendChild(cmdHint); + } + // ov CLI 连接断开可自愈:给一个「重连」按钮直接调 ensureOvCli。 + if (key === 'openvikingCli' && !ok && items.openviking && items.openviking.ok) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn--tiny'; + btn.textContent = t('envOvCliReconnect'); + btn.onclick = async () => { + btn.disabled = true; + try { + const res = await app.call('loopx.ensureOvCli'); + if (res && res.ok) { + const check = await app.call('loopx.envCheck'); + if (check && check.items) renderEnvChecklist(check.items); + return; + } + } catch (_) {} + btn.disabled = false; + }; + li.appendChild(btn); + } + list.appendChild(li); + } +} + +// Inline gh login from the env checklist: the same one-click `gh auth login +// --web` flow as the token dialog, but self-contained so the onboarding works +// without opening that dialog. +async function runGhLoginFromOnboard(btn) { + btn.disabled = true; + btn.textContent = t('ghLoginWorking'); + try { + const res = await app.call('loopx.ghLogin', {}); + if (!res || !res.ok) throw new Error((res && res.error) || 'gh login failed'); + S.ghAvailable = true; + btn.textContent = t('ghLoginDoneShort'); + log(`[gh] login complete (${res.login || '?'})`); + const check = await app.call('loopx.envCheck'); + if (check && check.items) renderEnvChecklist(check.items); + } catch (err) { + const message = String(err && err.message || err); + setTaskFeedback(`${t('ghLoginFailed')}:${message}`, 'error'); + btn.disabled = false; + btn.textContent = t('ghLoginBtn'); + } +} + +async function envOnboardFillDefaults() { + try { + const res = await app.call('loopx.defaultVlmConfig'); + const vlm = res && res.vlm; + if (vlm) { + const model = document.getElementById('env-cfg-vlm-model'); + const base = document.getElementById('env-cfg-vlm-base'); + const key = document.getElementById('env-cfg-vlm-key'); + if (model) model.value = vlm.model || 'deepseek-v4-flash'; + if (base) base.value = vlm.api_base || 'https://api.openbitfun.com/v1'; + if (key) key.value = vlm.api_key || ''; + } + } catch (_) { + // Defaults stay empty; the user can type credentials in the advanced block. + } +} + +// Synchronous readiness cache so boot() can decide to gate with the overlay +// BEFORE its first await — otherwise the composer paints ahead of the gate on +// a first launch. localStorage is a best-effort fast path; the durable source +// of truth is S.config.envReadyAt (app.storage). Both are written together. +const ENV_READY_CACHE_KEY = 'bitfun-loopx.envReadyAt'; +function envReadyCacheGet() { + try { + if (typeof localStorage === 'undefined') return 0; + return Number(localStorage.getItem(ENV_READY_CACHE_KEY)) || 0; + } catch (_) { return 0; } +} +function envReadyCacheSet(ts) { + try { + if (typeof localStorage === 'undefined') return; + if (ts) localStorage.setItem(ENV_READY_CACHE_KEY, String(ts)); + else localStorage.removeItem(ENV_READY_CACHE_KEY); + } catch (_) {} +} +function envMarkReady(ts = Date.now()) { + S.config.envReadyAt = ts; + envReadyCacheSet(ts); + saveConfig(); +} +function envMarkNotReady() { + S.config.envReadyAt = 0; + envReadyCacheSet(0); + saveConfig(); +} +// "Ready" gates on INSTALLED deps, not on whether the OpenViking server is +// currently listening: the server is a runtime state the console can (re)start +// on its own, so a reboot that leaves the server down must NOT re-open the +// setup guide every launch. +function envInstalledReady(items) { + return !!(items + && items.python && items.python.ok + && items.git && items.git.ok + && items.loopx && items.loopx.ok + && items.openviking && items.openviking.ok + && items.openvikingServer && items.openvikingServer.ok); +} + +// silent=true means we already believe the env is ready (confirmed on a prior +// launch): run the check in the background with NO overlay, and only surface +// the guide if something actually broke. silent=false is the first-launch path: +// the overlay (loading) is already visible and we flip to guide or pass. +async function runEnvOnboarding({ silent = false } = {}) { + const overlay = document.getElementById('env-onboard'); + if (!overlay) return; + if (!silent) { + envOnboardShowState('loading'); + envOnboardSetVisible(true); + } + let check = null; + try { + check = await app.call('loopx.envCheck'); + } catch (_) { + check = null; + } + if (!check) { + // Could not probe (worker error etc.) — never block the user on it. + if (!silent) envOnboardSetVisible(false); + return; + } + if (envInstalledReady(check.items)) { + // Deps are installed. If the OpenViking server is merely not listening + // (e.g. after a reboot), (re)start it silently instead of gating the user. + const running = check.items.openvikingRunning && check.items.openvikingRunning.ok; + if (!running) { + try { await app.call('loopx.startOvServer'); } catch (_) {} + } + // The `ov` CLI needs a saved config pointing at the server before loopx's + // repository/reward memory can reach it. Installed binaries are not the + // same as a connected CLI — self-heal (re-register + verify) when missing, + // so a prior partial setup can never leave memory silently dead. + const cliConnected = check.items.openvikingCli && check.items.openvikingCli.ok; + if (!cliConnected) { + try { await app.call('loopx.ensureOvCli'); } catch (_) {} + } + envMarkReady(); + if (!silent) envOnboardSetVisible(false); + return; + } + // Deps actually missing: this covers BOTH first launch and a later breakage + // detected by the silent background re-check. + renderEnvChecklist(check.items || {}); + await envOnboardFillDefaults(); + envOnboardShowState('guide'); + envOnboardSetVisible(true); + envMarkNotReady(); +} + +function envOnboardAppendLog(line) { + const logEl = document.getElementById('env-onboard-log'); + if (!logEl) return; + logEl.textContent += String(line || '') + '\n'; + logEl.scrollTop = logEl.scrollHeight; +} + +async function runEnvInstall() { + envOnboardShowState('progress'); + const logEl = document.getElementById('env-onboard-log'); + if (logEl) logEl.textContent = ''; + const vlm = { + provider: 'openai', + model: (document.getElementById('env-cfg-vlm-model') || {}).value || 'deepseek-v4-flash', + api_base: (document.getElementById('env-cfg-vlm-base') || {}).value || 'https://api.openbitfun.com/v1', + api_key: (document.getElementById('env-cfg-vlm-key') || {}).value || '', + }; + try { + // 0) python/git 是本流程装不了的硬前置:先探测,缺了直接给出安装指引并 + // 终止,而不是让后面的 pip 以 spawn ENOENT 失败打转。 + let check0 = null; + try { check0 = await app.call('loopx.envCheck'); } catch (_) { check0 = null; } + const items0 = (check0 && check0.items) || {}; + if (items0.python && !items0.python.ok) { + envOnboardAppendLog(t('prereqNeedPython')); + envOnboardShowState('guide'); + renderEnvChecklist(items0); + return; + } + if (items0.git && !items0.git.ok) { + envOnboardAppendLog(t('prereqNeedGit')); + envOnboardShowState('guide'); + renderEnvChecklist(items0); + return; + } + // 1) 缺 loopx 引擎先装(vendor 拉源码,免 pip):没有它整个产品无法工作, + // 只装 OpenViking 却宣布「环境就绪」是自相矛盾。 + if (items0.loopx && !items0.loopx.ok) { + envOnboardAppendLog('$ 拉取 loopx 源码(vendor,无需 pip)'); + const vend = await app.call('loopx.ensureVendor'); + if (!vend || !vend.ok) { + envOnboardAppendLog(`loopx 安装失败:${(vend && vend.error) || 'unknown'}`); + envOnboardShowState('guide'); + return; + } + envOnboardAppendLog('✓ loopx 就绪'); + } + // 2) 缺 OpenViking 才装 + let needInstall = !(items0.openviking && items0.openviking.ok); + if (needInstall) { + envOnboardAppendLog('$ pip install openviking[local-embed]'); + const inst = await app.call('loopx.installOpenViking'); + if (!inst || !inst.ok) { + envOnboardAppendLog(`安装失败:${(inst && inst.error) || 'unknown'}`); + envOnboardShowState('guide'); + return; + } + envOnboardAppendLog('✓ OpenViking 安装完成'); + } + // 3) 写 ov.conf + envOnboardAppendLog('$ 写入 ~/.openviking/ov.conf'); + const conf = await app.call('loopx.writeOvConf', { vlm }); + if (!conf || !conf.ok) { + envOnboardAppendLog(`配置写入失败:${(conf && conf.error) || 'unknown'}`); + envOnboardShowState('guide'); + return; + } + envOnboardAppendLog('✓ ov.conf 写入完成'); + if (conf.vlmKeyEmpty) envOnboardAppendLog(`⚠ ${t('envVlmKeyEmpty')}`); + // 4) 起 server + envOnboardAppendLog('$ openviking-server'); + const svc = await app.call('loopx.startOvServer'); + if (!svc || !svc.ok) { + envOnboardAppendLog(`启动失败:${(svc && svc.error) || 'unknown'}`); + envOnboardShowState('guide'); + return; + } + envOnboardAppendLog('✓ OpenViking server 已启动(后台)'); + // 5) 连 CLI + envOnboardAppendLog('$ ov config add custom --url http://127.0.0.1:1933 --activate'); + const cli = await app.call('loopx.ovConfigCli'); + envOnboardAppendLog(cli && cli.ok ? '✓ ov CLI 已连接' : `⚠ ${(cli && cli.error) || 'ov 连接失败'}`); + // 6) 完成前真实复检:spawn 成功 ≠ server 活着。轮询 envCheck(最多 ~15s), + // 全绿才进 done——避免「✓ 环境就绪」和下次启动被门禁拦回自相矛盾。 + envOnboardAppendLog(t('envVerifying')); + let finalCheck = null; + for (let i = 0; i < 15; i += 1) { + try { finalCheck = await app.call('loopx.envCheck'); } catch (_) { finalCheck = null; } + if (finalCheck && envInstalledReady(finalCheck.items) + && finalCheck.items.openvikingRunning && finalCheck.items.openvikingRunning.ok) break; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (finalCheck && envInstalledReady(finalCheck.items)) { + envOnboardAppendLog(`✓ ${t('envVerifyOk')}`); + envOnboardShowState('done'); + } else { + envOnboardAppendLog(`⚠ ${t('envVerifyFailed')}`); + renderEnvChecklist((finalCheck && finalCheck.items) || {}); + envOnboardShowState('guide'); + } + } catch (err) { + envOnboardAppendLog(`出错:${String(err && err.message || err)}`); + envOnboardShowState('guide'); + } +} + +app.on('worker:installOpenViking:progress', (d) => { + if (d && d.line != null) envOnboardAppendLog(d.line); +}); +// loopx vendor progress also streams into the env install log when the +// onboarding flow is the one driving it (progress state visible). +app.on('worker:vendorLoopx:progress', (d) => { + const progressEl = document.getElementById('env-state-progress'); + if (progressEl && !progressEl.hidden && d && d.line != null) envOnboardAppendLog(d.line); +}); + +const envBtnInstall = document.getElementById('btn-env-install'); +const envBtnRetry = document.getElementById('btn-env-retry'); +const envBtnDone = document.getElementById('btn-env-done'); +if (envBtnInstall) envBtnInstall.addEventListener('click', runEnvInstall); +if (envBtnRetry) envBtnRetry.addEventListener('click', () => runEnvOnboarding()); +if (envBtnDone) envBtnDone.addEventListener('click', () => { + // Setup completed: mark the env ready so later launches skip the gate + // (they still re-check silently in the background). + envMarkReady(); + envOnboardSetVisible(false); + requestRender(true); +}); + +// ── boot ────────────────────────────────────────────────── +(async function boot() { + dbgUi('boot:start', `t=${bootMs()}ms readyState=${document.readyState} theme=${themeProbe()}`); + // Env gate: if we have never confirmed the environment ready, cover the + // composer with the loading overlay SYNCHRONOUSLY (before any await) so it + // never paints ahead of the setup gate on a first launch. Confirmed installs + // skip this entirely and re-check silently in the background. + const envKnownReady = envReadyCacheGet() > 0; + if (!envKnownReady) { + envOnboardShowState('loading'); + envOnboardSetVisible(true); + } + await loadConfig(); + // Reconcile the durable stamp with the sync cache (localStorage may be + // unavailable or cleared): if the durable flag says ready, skip the gate. + if (!envKnownReady && S.config.envReadyAt > 0) { + envReadyCacheSet(S.config.envReadyAt); + envOnboardSetVisible(false); + } + 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(); + runEnvOnboarding({ silent: envKnownReady || S.config.envReadyAt > 0 }); + // 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. + // BUT the "was running before" fact must survive: snapshot it and offer a + // one-click 全部继续, otherwise a 10-issue unattended batch dies silently + // at every app update/restart with only per-card 继续 to recover. + let pausedAny = false; + const runningBefore = []; + for (const g of S.goals.values()) { + if (g.autoRun) { + g.autoRun = false; + S.config.autoRunByGoal[g.goalId] = false; + if (!g.archived && !isTerminal(g)) runningBefore.push(g.goalId); + pausedAny = true; + } + } + if (pausedAny) saveConfig(); + if (runningBefore.length) { + S.bootPausedGoalIds = runningBefore; + showBootResumeBanner(runningBefore); + } + // 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..f5a5e9b29f --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js @@ -0,0 +1,3916 @@ +// 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 http = require('http'); +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 } + +// 续跑对账缓存:`todo list` 只会在 turn 完成后变化,短时间内重复查询纯属浪费。 +// key = goalId@projectDir;60s 内直接复用上一轮对账结果。 +const RESUME_RECON_TTL_MS = 60 * 1000; +let resumeReconCache = null; // { key, at, resume, resumeNote } + +// 外部已解决(issue 被维护者关闭)的实时复核结果缓存:GitHub 匿名配额很小, +// 不能每次轮询都打一遍。key = goalId;10 分钟内复用,且受全局限流退避约束。 +const LIVE_ISSUE_CHECK_TTL_MS = 10 * 60 * 1000; +const LIVE_ISSUE_CHECK_MAX_ISSUES = 10; // cap per-cycle issue-status lookups +const liveIssueCheckCache = new Map(); // goalId -> { at, resolved } + +// 增量 OpenViking 同步:仓库 HEAD 没变就不用再整仓 add-resource 一次。 +// key = repoLabel -> 上次成功同步时的 HEAD sha。 +const ovSyncRevision = new Map(); + +// loopx serve-status 后台进程(reward 反馈走它的 loopback-only HTTP /reward/append)。 +// 按需拉起、进程内复用;跨 projectDir 切换时换一个端口重启。 +let statusServerProc = null; // { child, port, projectDir } +const STATUS_SERVER_PORTS = [18711, 18712, 18713]; + +// 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 }; +} + +// Mirror of absolutePythonExes for git: the worker can inherit a restricted +// PATH, and a git installed at its default location must not be reported +// missing (that would send the user to install something they already have). +function absoluteGitExes() { + const list = []; + if (process.platform === 'win32') { + for (const programFiles of [process.env.ProgramFiles, process.env['ProgramFiles(x86)']]) { + if (programFiles) list.push(path.join(programFiles, 'Git', 'cmd', 'git.exe')); + } + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) list.push(path.join(localAppData, 'Programs', 'Git', 'cmd', 'git.exe')); + } else { + list.push('/opt/homebrew/bin/git', '/usr/local/bin/git', '/usr/bin/git'); + } + return list.filter((p) => { try { return fs.existsSync(p); } catch (_) { return false; } }); +} + +async function probeGit() { + for (const argv of [['git'], ...absoluteGitExes().map((exe) => [exe])]) { + try { + const { code, stdout, stderr } = await spawnLoopx({ argv }, ['--version'], { timeoutMs: 8000 }); + if (code === 0) return { found: true, version: (stdout + stderr).trim().split(/\r?\n/)[0] || null, exe: argv[0] }; + } catch (_) { /* try the next candidate */ } + } + return { found: false, version: null, exe: 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 }; + } +} + +// loopx bootstrap hardcodes coordination.requires_parent_approval = +// ["write", "publish", "production-action"], which makes EVERY repo write emit +// an "Owner approval gate" user_gate — even though the intake confirmation +// sheet already granted --write-scope write (that flag only sets write_scope, +// it does NOT clear requires_parent_approval). For an auto-fix console the +// owner has already consented to code edits, so drop "write" from the list and +// keep publish + production-action gated (a public PR still deserves review). +// Registry surgery with the same timestamped-backup discipline as deleteGoal; +// idempotent — a registry without "write" in the list is left untouched. +function clearWriteApprovalRequirement(projectDir, goalId) { + const candidates = [...new Set([resolveRegistryPath(projectDir), resolveRegistryPath(null)])]; + let changed = 0; + for (const registryPath of candidates) { + try { + if (!fs.existsSync(registryPath)) continue; + const raw = fs.readFileSync(registryPath, 'utf8'); + const registry = JSON.parse(raw); + if (!Array.isArray(registry.goals)) continue; + for (const goal of registry.goals) { + const id = goal.goal_id || goal.id; + if (id !== goalId) continue; + const coord = goal.coordination && typeof goal.coordination === 'object' ? goal.coordination : {}; + const list = Array.isArray(coord.requires_parent_approval) + ? coord.requires_parent_approval + : []; + if (!list.includes('write')) continue; + coord.requires_parent_approval = list.filter((v) => v !== 'write'); + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + fs.copyFileSync(registryPath, `${registryPath}.write-bak-${stamp}`); + registry.updated_at = new Date().toISOString(); + fs.writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`); + dbgWorker('clearWriteApproval:done', `${id} -> ${JSON.stringify(coord.requires_parent_approval)} (${registryPath})`); + changed += 1; + } + } catch (err) { + dbgWorker('clearWriteApproval:error', `${registryPath}: ${err.message || err}`); + } + } + return changed; +} + +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 命令失败,先读错误信息再修正调用方式 —— 同一命令不要盲目重试超过两次。', + '规则:不要反复读取同一个文件、不要反复复述同一个计划。每读一个文件后必须立刻采取一个实际动作(写代码、跑命令、或更新 loopx todo),禁止陷入「读取 → 复述 → 再读取」的空转循环。', + '如果连续读完两个文件后仍不确定下一步该做什么,直接结束本轮,用中文说明卡在哪里、需要什么信息,不要继续空转。', + '规则:任何可能长时间运行的命令(python 脚本、网络请求、服务/后台进程、import 大模块)都必须显式带超时;禁止运行交互式、等待 stdin 输入、或无限循环的命令 —— 这类命令会让本轮卡死。', + `提交规范:每次 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; + } +} + +// ── OpenViking repository memory (issue-fix workflow-plan) ───────────── +// loopx only queries repository memory when the issue-fix plan command receives +// BOTH a repository-context JSON (pinning the checkout revision) AND a provider +// config (openviking). Without either, the planner silently skips memory — so +// we build both here and inject them explicitly. Everything fails open: a +// missing git HEAD or a temp-file write just yields no memory args. +function repoMemoryScopeRef(repoLabel) { + const slug = String(repoLabel || '').replace(/[^A-Za-z0-9._-]/g, '-'); + return `viking://resources/${slug}/main`; +} + +async function gitHeadRevision(repoDir) { + if (!repoDir) return null; + try { + const { code, stdout } = await spawnLoopx( + { argv: ['git'] }, ['-C', repoDir, 'rev-parse', 'HEAD'], { timeoutMs: 15000 }, + ); + const sha = String(stdout || '').trim(); + return code === 0 && /^[0-9a-fA-F]{12,64}$/.test(sha) ? sha : null; + } catch (_) { + return null; + } +} + +function writeJsonTemp(prefix, obj) { + const file = path.join(os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(file, JSON.stringify(obj), 'utf8'); + return file; +} + +function repositoryMemoryProviderConfig(repoLabel) { + return { + schema_version: 'issue_fix_repository_memory_provider_config_v0', + enabled: true, + provider: 'openviking', + namespace: 'public-repository', + visibility: 'public', + revision_policy: 'rolling_default_branch', + scope_ref: repoMemoryScopeRef(repoLabel), + resource_references: [], + max_results: 3, + timeout_seconds: 15, + sync_timeout_seconds: 180, + }; +} + +async function buildRepositoryMemoryArgs(repoLabel, repoDir) { + const scopeRef = repoMemoryScopeRef(repoLabel); + const revision = await gitHeadRevision(repoDir); + if (!revision) return { args: [], scopeRef }; + const ctx = { + schema_version: 'issue_fix_repository_context_input_v0', + repository_revision: revision, + sources: [], + }; + const ctxPath = writeJsonTemp('bitfun-loopx-repoctx', ctx); + const cfgPath = writeJsonTemp('bitfun-loopx-repomem', repositoryMemoryProviderConfig(repoLabel)); + return { + args: ['--repository-context-json', ctxPath, '--repository-memory-provider-json', cfgPath], + scopeRef, + }; +} + +// Does the OpenViking scope actually have content? `ov add-resource` can return +// exit 0 with {"ok":false,…} (a DEADLINE_EXCEEDED on the final commit) even +// though the files and vectors already landed — the resource tree is the real +// source of truth, not the CLI's exit code or its JSON `ok` field. +async function ovResourceExists(scopeRef) { + if (!scopeRef) return false; + try { + const ls = await spawnLoopx({ argv: ['ov'] }, ['ls', scopeRef, '-o', 'json', '-c', 'true'], { timeoutMs: 15000 }); + if (ls.code !== 0) return false; + const parsed = parseJsonPayload(String(ls.stdout || '')); + const list = parsed && Array.isArray(parsed.result) ? parsed.result : null; + return Array.isArray(list) && list.length > 0; + } catch (_) { + return false; + } +} + +// Best-effort: seed the repo's public source into OpenViking so repository +// memory actually has content to recall. `ov add-resource` on a folder with +// --ignore-dirs keeps the index bounded; failures are non-fatal (memory simply +// starts empty and fills on a later successful run). +async function syncRepoToOpenViking({ repoLabel, repoDir }) { + if (!repoLabel || !repoDir) return { ok: false, reason: 'no_repo' }; + const scopeRef = repoMemoryScopeRef(repoLabel); + try { + // 增量:HEAD 与上次成功同步一致 → 跳过整仓重传(本地未产生新提交就没新内容)。 + const head = await gitHeadRevision(repoDir); + if (head && ovSyncRevision.get(repoLabel) === head) { + return { ok: true, scopeRef, skipped: true }; + } + // No --wait: the upload returns immediately and the server indexes in the + // background, so a first intake never blocks for minutes. Memory fills in + // for the agent's subsequent turns. Note: the local file upload itself is + // synchronous, so a large repo (several thousand files) needs a generous + // timeout — 60s was killing the seed before it finished, leaving the scope + // empty and the goal silently memory-less. + const { code, stdout, stderr } = await spawnLoopx( + { argv: ['ov'] }, + [ + 'add-resource', repoDir, + '--parent-auto-create', scopeRef, + '--ignore-dirs', '.git,node_modules,dist,build,.venv,venv,__pycache__,target', + '-o', 'json', '-c', 'true', + ], + { timeoutMs: 240000 }, + ); + const landed = await ovResourceExists(scopeRef); + const ok = landed; + if (ok && head) { ovSyncRevision.set(repoLabel, head); saveGhState(); } + dbgWorker('repoMemory:sync', `ok=${ok} scope=${scopeRef} code=${code} landed=${landed}`); + return { ok, scopeRef, error: ok ? null : String(stderr || stdout).slice(-200) }; + } catch (err) { + dbgWorker('repoMemory:syncError', String(err && err.message || err)); + return { ok: false, scopeRef, error: String(err && err.message || err) }; + } +} + +// ── Reward memory (loopx reward_memory_experiment_config_v1) ─────────── +// loopx enables reward memory only when configure-goal receives an explicit +// --reward-memory-config + --reward-memory-agent. We write a canonical +// issue-fix patch-planning experiment (openviking provider) and register it on +// the goal right after the agent is registered. +function rewardMemoryConfigPath(projectDir) { + return path.join(projectDir, '.loopx', 'config', 'reward-memory', 'experiment.json'); +} + +function rewardMemoryExperimentConfig(repoLabel) { + const slug = String(repoLabel || '').replace(/[^A-Za-z0-9._-]/g, '-'); + const workspaceRef = 'workspace:bitfun-loopx'; + const projectRef = `repository:${slug}`; + const scopeRef = `viking://resources/reward-memory/${slug}`; + const corpusId = 'openviking_patch_policy'; + const ownerRef = 'openviking_reward_memory_owner'; + return { + schema_version: 'reward_memory_experiment_config_v1', + project_provider_binding: { + provider_id: 'openviking', + namespace: 'reward_memory', + timeout_seconds: 30, + minimum_provider_version: '0.4.9', + corpus_scopes: [{ corpus_id: corpusId, scope_ref: scopeRef }], + }, + corpora: [{ + corpus: { + corpus_id: corpusId, + class_id: 'hard_policy', + provider_id: 'openviking', + owner_ref: ownerRef, + source_of_truth: 'reviewed_maintainer_feedback', + read_authority: 'module_scoped', + write_authority: 'provider_managed', + scope: { workspace_ref: workspaceRef, project_ref: projectRef, surface_ids: ['issue_fix.patch_planning'] }, + freshness: { mode: 'revision_bound', source_revision: 'revision:rolling' }, + lifecycle: { state: 'active', supersedes: [] }, + retrieval: { index_required: true, readback_required: true, application_receipt_required: true }, + maintenance: { + writeback_triggers: ['reviewed_maintainer_feedback'], + closure_policy: 'write_exact_readback_then_recall', + retirement_authority: ownerRef, + }, + privacy: { visibility: 'private', raw_content_in_registry: false }, + }, + standing_policy: { + schema_version: 'reward_memory_standing_policy_v0', + policy_id: 'policy:openviking:issue-fix-maintainer-feedback', + enabled: true, + auto_activate: true, + owner_ref: ownerRef, + reviewer_ref: 'agent:bitfun-agent', + authority_source_ref: 'policy:openviking:issue-fix', + scope: { workspace_ref: workspaceRef, project_ref: projectRef, surface_ids: ['issue_fix.patch_planning'] }, + allowed_target_classes: ['hard_policy'], + allowed_source_kinds: ['maintainer_correction'], + allowed_actor_roles: ['verified_repository_core_contributor'], + allowed_action_scopes: ['issue_fix:scope_selection'], + raw_content_captured: false, + }, + }], + surfaces: [{ + surface_id: 'issue_fix.patch_planning', + adapter: 'issue_fix_maintainer_feedback', + corpus_ids: [corpusId], + ingest_corpus_id: corpusId, + recall_profile: { profile_id: 'patch_planning_v1', mode: 'bounded_agentic_search', max_queries: 2, limit: 5 }, + }], + automation: { automatic_recall: true, automatic_ingest: true, fail_open: true }, + }; +} + +async function enableRewardMemory({ argvPrefix, srcDir, projectDir, goalId, agentId, repoLabel }) { + if (!projectDir || !goalId || !agentId || !repoLabel) { + return { ok: false, reason: 'missing_args' }; + } + try { + const confPath = rewardMemoryConfigPath(projectDir); + fs.mkdirSync(path.dirname(confPath), { recursive: true }); + fs.writeFileSync(confPath, JSON.stringify(rewardMemoryExperimentConfig(repoLabel), null, 2), 'utf8'); + const { result, payload } = await runJson(argvPrefix, projectDir, [ + 'configure-goal', '--goal-id', goalId, + '--reward-memory-config', '.loopx/config/reward-memory/experiment.json', + '--reward-memory-agent', agentId, + '--execute', + ], { srcDir, timeoutMs: 60000 }); + const ok = result.code === 0 && payload?.ok !== false; + dbgWorker('rewardMemory:done', `ok=${ok} ${(payload && payload.error) || ''}`); + return { ok, error: ok ? null : (payload?.error || result.stderr.slice(0, 300) || 'configure-goal failed') }; + } catch (err) { + dbgWorker('rewardMemory:error', String(err && err.message || err)); + return { ok: false, error: String(err && err.message || err) }; + } +} + +async function planIssueIntake({ argvPrefix, srcDir, projectDir, url, repoLabel = null }) { + const planArgsBase = ['issue-fix', 'workflow-plan', '--url', String(url).trim()]; + if (projectDir) planArgsBase.push('--repo-path', projectDir); + let memoryArgs = []; + try { + memoryArgs = (await buildRepositoryMemoryArgs(repoLabel, projectDir)).args; + } catch (err) { + dbgWorker('repoMemory:argsError', String(err && err.message || err)); + } + let result, payload; + ({ result, payload } = await runJson(argvPrefix, projectDir, [ + ...planArgsBase, '--fetch-metadata', '--fetch-timeout-seconds', '20', ...memoryArgs, + ], { 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, ...memoryArgs], { + 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). +// +// Read-only GET cache + rate-limit backoff: the live issue re-check polls the +// same /repos/{r}/issues/{n} endpoints repeatedly, and anonymous quota is tiny. +// Cache successful GETs briefly (issue state rarely flips within minutes) and, +// once GitHub reports 0 remaining, stop issuing GETs until the reset window. +const GH_GET_CACHE_TTL_MS = 5 * 60 * 1000; +const ghGetCache = new Map(); // apiPath -> { at, value } +let ghRateLimitedUntil = 0; // epoch ms; GETs short-circuit until this passes + +// ── persisted GitHub request state ───────────────────────── +// ghRateLimitedUntil and the per-goal live-check cache survive a worker +// restart: the first stops re-hammering GitHub right after a rate limit, the +// second avoids re-checking the same goals on every boot. The per-URL GET +// cache stays in-memory only (bounded + short TTL). +const GH_STATE_FILE = path.join(consoleHome(), 'gh-request-cache.json'); +function loadGhState() { + try { + const raw = fs.readFileSync(GH_STATE_FILE, 'utf8'); + const obj = JSON.parse(raw); + if (obj && typeof obj.rateLimitedUntil === 'number' && obj.rateLimitedUntil > Date.now()) { + ghRateLimitedUntil = obj.rateLimitedUntil; + } + if (obj && obj.liveIssueCheck && typeof obj.liveIssueCheck === 'object') { + for (const [goalId, v] of Object.entries(obj.liveIssueCheck)) { + if (v && typeof v.at === 'number' && Array.isArray(v.resolved)) { + liveIssueCheckCache.set(goalId, { at: v.at, resolved: v.resolved, cursor: Number.isInteger(v.cursor) ? v.cursor : 0 }); + } + } + } + // repoLabel -> HEAD sha of the last successful OpenViking seed: without + // this a worker restart forgets the sync and re-uploads the whole repo. + if (obj && obj.ovSyncRevision && typeof obj.ovSyncRevision === 'object') { + for (const [repoLabel, sha] of Object.entries(obj.ovSyncRevision)) { + if (typeof sha === 'string' && sha) ovSyncRevision.set(repoLabel, sha); + } + } + } catch (_) { /* first run / unreadable: ignore */ } +} +function saveGhState() { + try { + fs.mkdirSync(path.dirname(GH_STATE_FILE), { recursive: true }); + const live = {}; + for (const [goalId, v] of liveIssueCheckCache) live[goalId] = { at: v.at, resolved: v.resolved, cursor: v.cursor || 0 }; + const ovRev = {}; + for (const [repoLabel, sha] of ovSyncRevision) ovRev[repoLabel] = sha; + fs.writeFileSync(GH_STATE_FILE, JSON.stringify({ + rateLimitedUntil: ghRateLimitedUntil, + liveIssueCheck: live, + ovSyncRevision: ovRev, + }), 'utf8'); + } catch (_) { /* best-effort */ } +} +loadGhState(); + +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) { + const remaining = res.headers['x-ratelimit-remaining']; + const resetSec = Number(res.headers['x-ratelimit-reset']); + if (remaining === '0' && resetSec > 0) { + ghRateLimitedUntil = resetSec * 1000 + 1000; + saveGhState(); + } + reject(new Error(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(); + }); +} + +// Cached read path for issue-status re-checks: avoids re-hitting the same +// endpoint every poll, and honors the global rate-limit backoff window. When a +// GitHub CLI token is available it authenticates the request, replacing the +// anonymous 60/h quota with the authenticated 5000/h quota. +let ghApiTokenCache = { at: 0, token: null }; +// PAT fallback: the miniapp stores a pasted token in its config storage; read +// it directly so method 1 (paste a PAT) also authenticates the GET path. +function readPatFromStorage() { + try { + const dir = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'bitfun', 'data', 'miniapps', 'builtin-bitfun-loopx'); + const obj = JSON.parse(fs.readFileSync(path.join(dir, 'storage.json'), 'utf8')); + return (obj && obj.config && typeof obj.config.githubToken === 'string' && obj.config.githubToken.trim()) || null; + } catch (_) { return null; } +} +async function githubApiToken() { + if (Date.now() - ghApiTokenCache.at < 5 * 60 * 1000) return ghApiTokenCache.token; + let token = await ghCliToken(); + if (!token) token = readPatFromStorage(); + ghApiTokenCache = { at: Date.now(), token }; + return token; +} + +async function githubApiGetCached(apiPath) { + if (Date.now() < ghRateLimitedUntil) { + throw new Error('GitHub API rate limit exceeded; retry later'); + } + const hit = ghGetCache.get(apiPath); + if (hit && Date.now() - hit.at < GH_GET_CACHE_TTL_MS) return hit.value; + const token = await githubApiToken(); + const value = await githubApiRequest(apiPath, { token }); + ghGetCache.set(apiPath, { at: Date.now(), value }); + if (ghGetCache.size > 500) { + const oldest = ghGetCache.keys().next().value; + ghGetCache.delete(oldest); + } + return value; +} + +// 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 === 'win32') { + // winget/MSI default to a machine-level install under Program Files, and + // the worker's PATH is fixed at spawn time — without these candidates a + // fresh `winget install GitHub.cli` succeeds but findGhExe still misses it. + for (const programFiles of [process.env.ProgramFiles, process.env['ProgramFiles(x86)']]) { + if (programFiles) list.push(path.join(programFiles, '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. ProxyServer is a sticky +// leftover value — the real on/off switch is ProxyEnable, so a disabled proxy +// (ProxyEnable=0) must resolve to null instead of routing installs into a +// dead proxy. +function readWindowsSystemProxy() { + const regQuery = (valueName) => new Promise((resolve) => { + try { + const child = spawn('reg', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', '/v', valueName], { windowsHide: true }); + let out = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (d) => { out += d; }); + child.on('error', () => resolve(null)); + child.on('close', () => resolve(out || null)); + } catch (_) { resolve(null); } + }); + return (async () => { + const enabledOut = await regQuery('ProxyEnable'); + const em = enabledOut && enabledOut.match(/ProxyEnable\s+REG_DWORD\s+0x([0-9a-f]+)/i); + if (!em || parseInt(em[1], 16) === 0) return null; + const serverOut = await regQuery('ProxyServer'); + const m = serverOut && serverOut.match(/ProxyServer\s+REG_SZ\s+([^\r\n]+)/i); + return m ? m[1].trim() : 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; +} + +// Proxy-aware fallback for the release version lookup: githubApiRequest uses +// Node https directly (no proxy support), so on proxied networks it fails +// even though the actual download (Invoke-WebRequest / curl) would work. +// Resolve https://github.com/cli/cli/releases/latest through the SAME +// proxy-aware channels and read the version out of the redirect target. +async function resolveLatestGhTag(emit) { + try { + if (process.platform === 'win32') { + // HttpWebRequest honors the WinINET system proxy by default. + const ps = "$r=[System.Net.HttpWebRequest]::Create('https://github.com/cli/cli/releases/latest');$r.AllowAutoRedirect=$false;$resp=$r.GetResponse();Write-Output $resp.Headers['Location'];$resp.Close()"; + const out = await spawnLoopx( + { argv: [powershellExe()] }, + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps], + { timeoutMs: 30000 }, + ); + const m = String(out.stdout || '').match(/releases\/tag\/(v[\d.]+)/); + return m ? m[1] : null; + } + // curl follows the redirect and reports the effective URL; honors + // https_proxy on POSIX. + const out = await spawnLoopx( + { argv: ['curl'] }, + ['-fsSLo', '/dev/null', '-w', '%{url_effective}', 'https://github.com/cli/cli/releases/latest'], + { timeoutMs: 30000 }, + ); + const m = String(out.stdout || '').match(/releases\/tag\/(v[\d.]+)/); + return m ? m[1] : null; + } catch (err) { + if (emit) emit(`版本重定向解析失败:${String(err.message || err).slice(0, 120)}`); + 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 redirect fallback below */ } + if (!url) { + // Version-stamped asset names (gh_<ver>_<platform>) rule out a static + // latest/download URL — resolve the version via the proxy-aware redirect + // instead of the (proxy-blind) API. + const tag = await resolveLatestGhTag(emit); + if (tag) { + const ver = tag.replace(/^v/, ''); + url = `https://github.com/cli/cli/releases/download/${tag}/gh_${ver}_${assetInfo.name}`; + emit('API 查询走不通,已通过重定向解析最新版本…'); + } + } + 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) { + // 0x8A15002B APPINSTALLER_CLI_ERROR_PACKAGE_ALREADY_INSTALLED: gh is + // already on the machine (worker PATH was just stale) — success, not + // failure; findGhExe re-resolves it from the absolute candidates. + const alreadyInstalled = (install.code >>> 0) === 0x8A15002B + || /already installed/i.test(String(install.stderr || install.stdout || '')); + if (!alreadyInstalled) { + throw new Error(String(install.stderr || install.stdout || '').trim().slice(-200) || `winget exit ${install.code}`); + } + emit('GitHub CLI 已安装(跳过重复安装)'); + } + } 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 githubApiGetCached(`/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 }; +} + +// Best-effort live status of one issue: an issue closed by a maintainer (or a +// merged PR) must NOT be re-fixed. Rate-limit / network failures fail open +// (open=true) so a transient lookup error never silently drops a real issue. +// An OPEN issue can still be effectively resolved: someone's fix PR merged +// without a "Fixes #N" (or the maintainer just hasn't closed it) — probe the +// timeline for a merged cross-referenced PR so the agent skips those too +// instead of shipping a duplicate fix. Same GET cache + backoff applies. +async function checkIssueStatus(repo, number) { + try { + const detail = await githubApiGetCached(`/repos/${repo}/issues/${number}`); + const open = !detail || detail.state !== 'closed'; + const base = { + ok: true, + open, + state: detail ? (detail.state || 'unknown') : 'unknown', + stateReason: detail ? (detail.state_reason || null) : null, + isPr: !!(detail && detail.pull_request), + }; + if (!open || base.isPr) return base; + try { + const timeline = await githubApiGetCached(`/repos/${repo}/issues/${number}/timeline?per_page=100`); + if (Array.isArray(timeline)) { + const mergedPr = timeline.find((ev) => ev && ev.event === 'cross-referenced' + && ev.source && ev.source.issue && ev.source.issue.pull_request + && ev.source.issue.pull_request.merged_at); + if (mergedPr) { + return { + ...base, + open: false, + state: 'open', + stateReason: 'linked_pr_merged', + linkedPrUrl: mergedPr.source.issue.pull_request.html_url || mergedPr.source.issue.html_url || null, + }; + } + } + } catch (_) { /* timeline probe is best-effort; the issue stays open */ } + return base; + } catch (err) { + dbgWorker('checkIssueStatus:error', `${repo}#${number}: ${err.message || err}`); + return { ok: false, open: true, state: 'unknown', stateReason: null, isPr: false }; + } +} + +// ── OpenViking (context database) support ───────────────── +// OpenViking is the external context provider loopx's repository-memory and +// reward-memory capabilities bind to through the `ov` CLI. This console gates +// the auto-fix surface behind a one-click environment check / install / +// configure flow so a fresh user never lands on a dead input box. +function whichOnPath(cmd) { + const probe = process.platform === 'win32' ? 'where' : 'which'; + return spawnLoopx({ argv: [probe] }, [cmd], { timeoutMs: 8000 }) + .then(({ code, stdout }) => (code === 0 && String(stdout).trim() ? String(stdout).trim().split(/\r?\n/)[0] : null)) + .catch(() => null); +} + +async function openvikingRunning() { + try { + const res = await fetch('http://127.0.0.1:1933/', { signal: AbortSignal.timeout(2500) }); + return { ok: res.status < 500 }; + } catch (_) { + return { ok: false }; + } +} + +// The `ov` CLI needs a saved ovcli.conf pointing at the local server before it +// can answer `search`/`read`/`add-resource`. The server binary being installed +// is NOT the same as the CLI being connected — a missing ovcli.conf makes every +// loopx repository/reward-memory provider call fail closed as "unavailable". +async function ovCliHealth() { + try { + const { code } = await spawnLoopx({ argv: ['ov'] }, ['health'], { timeoutMs: 8000 }); + return code === 0; + } catch (_) { + return false; + } +} + +// Idempotent: register the local custom server config and activate it. +function runOvConfigCli() { + return spawnLoopx( + { argv: ['ov'] }, + ['config', 'add', 'custom', '--name', 'local', '--url', 'http://127.0.0.1:1933', '--activate'], + { timeoutMs: 30000 }, + ); +} + +function openvikingConfPath() { + return path.join(os.homedir(), '.openviking', 'ov.conf'); +} + +// Best-effort default VLM: reuse the OpenAI-compatible model BitFun already has +// configured (OpenBitFun), so the wizard can pre-fill provider/model/base/key. +// Never throws — a missing app.json just yields null and the UI falls back to +// letting the user type credentials. +function readBitfunVlmDefault() { + try { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + const appJson = path.join(appData, 'bitfun', 'config', 'app.json'); + if (!fs.existsSync(appJson)) return null; + const cfg = JSON.parse(fs.readFileSync(appJson, 'utf8')); + const models = (cfg.ai && cfg.ai.models) || []; + const ovs = models.filter((m) => m && /openbitfun/i.test(String(m.id || m.name || ''))); + if (!ovs.length) return null; + // Prefer the "flash" model (cheap/fast VLM) when both flash and pro exist. + const ov = ovs.find((m) => /flash/i.test(String(m.id || m.name || ''))) || ovs[0]; + return { + provider: 'openai', + model: ov.model_name || 'deepseek-v4-flash', + api_base: ov.base_url || 'https://api.openbitfun.com/v1', + api_key: ov.api_key || '', + }; + } catch (_) { + return null; + } +} + +// Project a goal's fix-issue todos into a per-issue board row (url/number/ +// title/status/done). Batch intake writes one agent todo per issue +// ("[P1] Fix GitHub issue #N: <title> (<url>)", action_kind=fix_issue), so the +// done/open split here IS the "which issues are already fixed vs still open" +// reconciliation used on resume. +function projectGoalIssues(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'; + // Blocking reason for the UI: `reason` is a short human summary, `resume_when` + // a technical resume condition, `note` a detailed paragraph. Keep all three so + // the UI can derive a localized (Chinese) summary while the full note stays on hover. + issues.push({ + url, number, title, status, + done: status === 'done', + todoId: td.todo_id ?? null, + reason: td.reason || null, + resumeWhen: td.resume_when || null, + note: td.note || td.reason || null, + }); + } + const done = issues.filter((issue) => issue.done).length; + return { issues, total: issues.length, done, open: issues.length - done }; +} + +// ── Human reward feedback (loopx serve-status HTTP) ────────────────── +// loopx exposes operator feedback ("this run was useful / not useful") through +// `loopx serve-status --enable-reward-write-api` on loopback only. The worker +// lazily starts that server, then posts a two-phase reward append: dry-run +// (gets a preview_id) -> append (writes the human_reward overlay into the run +// index + ACTIVE_GOAL_STATE.md's Recent User Feedback). See loopx's own +// examples/reward-append-api-smoke.py for the exact contract. + +function httpJson(method, port, route, body) { + return new Promise((resolve, reject) => { + const data = body == null ? null : JSON.stringify(body); + const req = http.request({ + hostname: '127.0.0.1', + port, + path: route, + method, + headers: { + ...(data ? { 'Content-Type': 'application/json' } : {}), + 'Content-Length': data ? Buffer.byteLength(data) : 0, + Origin: 'http://127.0.0.1', + }, + timeout: 5000, + }, (res) => { + let buf = ''; + res.setEncoding('utf8'); + res.on('data', (c) => { buf += c; }); + res.on('end', () => { + let payload = null; + try { payload = buf ? JSON.parse(buf) : null; } catch (_) {} + resolve({ status: res.statusCode || 0, payload }); + }); + }); + req.on('timeout', () => { req.destroy(new Error('status server timeout')); }); + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +async function statusServerHealth(port) { + try { + const res = await httpJson('GET', port, '/healthz', null); + return res.status === 200 && res.payload && res.payload.ok === true; + } catch (_) { + return false; + } +} + +async function ensureStatusServer(argvPrefix, projectDir) { + const argv = Array.isArray(argvPrefix) ? argvPrefix : (argvPrefix && argvPrefix.argv) || []; + const envOverlay = Array.isArray(argvPrefix) ? null : (argvPrefix && argvPrefix.env); + // Reuse the live server when it belongs to the same project and answers health. + if (statusServerProc && statusServerProc.projectDir === projectDir + && statusServerProc.child && statusServerProc.child.exitCode == null) { + if (await statusServerHealth(statusServerProc.port)) return statusServerProc; + } + // Stop a stale server before starting a new one. + if (statusServerProc && statusServerProc.child) { + try { statusServerProc.child.kill(); } catch (_) {} + statusServerProc = null; + } + let lastErr = null; + for (const port of STATUS_SERVER_PORTS) { + const [cmd, ...prefixArgs] = argv.length ? argv : ['loopx']; + const child = spawn(cmd, [ + ...prefixArgs, + ...registryArgs(projectDir), + 'serve-status', '--host', '127.0.0.1', '--port', String(port), + '--scan-root', projectDir, '--enable-reward-write-api', + ], { + shell: false, + windowsHide: true, + detached: process.platform !== 'win32', + cwd: projectDir, + env: buildEnv(envOverlay), + stdio: 'ignore', + }); + // Wait for health (bounded). + const deadline = Date.now() + 15000; + let healthy = false; + while (Date.now() < deadline) { + if (await statusServerHealth(port)) { healthy = true; break; } + await new Promise((r) => setTimeout(r, 250)); + } + if (healthy) { + statusServerProc = { child, port, projectDir }; + dbgWorker('reward:statusServer', `up port=${port} project=${projectDir}`); + return statusServerProc; + } + lastErr = `serve-status did not become healthy on port ${port}`; + try { child.kill(); } catch (_) {} + } + throw new Error(lastErr || 'could not start serve-status'); +} + +// Latest run's generated_at from `loopx history` markdown (first table row). +async function latestRunGeneratedAt(argvPrefix, projectDir, goalId) { + const { code, stdout } = await spawnLoopx( + argvPrefix, + [...registryArgs(projectDir), 'history', '--goal-id', goalId, '--limit', '1'], + { timeoutMs: 30000 }, + ); + if (code !== 0) return null; + const m = String(stdout || '').match(/\|\s*`([^`]+)`\s*\|/); + return m ? m[1].trim() : null; +} + +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 }; + }, + + // OpenViking environment gate: one structured answer for the onboarding page. + async 'loopx.envCheck'({} = {}) { + const [python, git, loopx, ovBin, ovServerBin, ovRunning, ovCli, ghToken] = await Promise.all([ + probePython(), + probeGit(), + detectLoopx(null, null), + whichOnPath('ov'), + whichOnPath('openviking-server'), + openvikingRunning(), + ovCliHealth(), + ghCliToken(), + ]); + const items = { + python: { ok: !!(python && python.ok), version: (python && python.version) || null }, + git: { ok: !!(git && git.found), version: (git && git.version) || null }, + loopx: { ok: !!(loopx && loopx.found), version: (loopx && loopx.version) || null }, + openviking: { ok: !!ovBin, version: null }, + openvikingServer: { ok: !!ovServerBin }, + openvikingRunning: { ok: !!ovRunning.ok }, + // True only when the `ov` CLI can actually reach the server — the real + // precondition for repository/reward memory (not just "binary present"). + openvikingCli: { ok: !!ovCli }, + // GitHub CLI login: a credential, not a hard dependency — it lifts the + // anonymous API quota and enables PR publish, but fixing issues locally + // does not require it. So it is reported for the checklist, not gated on. + gh: { ok: !!ghToken }, + }; + const ready = items.python.ok && items.git.ok && items.loopx.ok + && items.openviking.ok && items.openvikingServer.ok && items.openvikingRunning.ok + && items.openvikingCli.ok; + return { ready, items }; + }, + + // One-click install of OpenViking (server + `ov` CLI + local CPU embedding). + // Progress lines stream through installOpenViking:progress events. + async 'loopx.installOpenViking'({} = {}) { + const emit = (line) => global.rpcEmit('installOpenViking:progress', { line }); + const target = 'openviking[local-embed]'; + const attempts = []; + for (const pythonExe of pythonCandidates()) { + attempts.push({ argv: [pythonExe, '-m', 'pip', 'install', target, '--upgrade'] }); + } + attempts.push({ argv: ['pip', 'install', target, '--upgrade'] }); + attempts.push({ argv: ['python', '-m', 'pip', 'install', target, '--upgrade'] }); + if (process.platform !== 'win32') { + attempts.push({ argv: ['python3', '-m', 'pip', 'install', target, '--upgrade'] }); + } + let lastError = null; + for (const prefix of attempts) { + emit(`$ ${prefix.argv.join(' ')}`); + try { + const { code, stderr } = await spawnLoopx(prefix, [], { + timeoutMs: 900000, + onStderrLine: (line) => emit(String(line).slice(0, 160)), + }); + if (code === 0) { + emit('install complete'); + return { ok: true }; + } + 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' }; + }, + + // Read the default VLM (OpenBitFun) already configured in BitFun, so the + // config page can pre-fill it instead of asking the user to paste keys. + async 'loopx.defaultVlmConfig'({} = {}) { + return { vlm: readBitfunVlmDefault() }; + }, + + // Write ~/.openviking/ov.conf non-interactively (replaces the init wizard). + // An existing conf is backed up and shallow-merged (only embedding/vlm are + // replaced) so re-running onboarding never destroys hand-tuned settings. + async 'loopx.writeOvConf'({ vlm = null, embedding = null } = {}) { + const confPath = openvikingConfPath(); + let existing = {}; + try { + if (fs.existsSync(confPath)) { + existing = JSON.parse(fs.readFileSync(confPath, 'utf8')) || {}; + fs.copyFileSync(confPath, `${confPath}.bak-${Date.now()}`); + } + } catch (_) { existing = {}; } + const config = { + ...existing, + embedding: { + dense: embedding || (existing.embedding && existing.embedding.dense) || { provider: 'local', model: 'bge-small-zh-v1.5-f16' }, + }, + vlm: vlm || existing.vlm || { provider: 'openai', model: 'deepseek-v4-flash', api_key: '', api_base: 'https://api.openbitfun.com/v1' }, + }; + // A blank api_key silently breaks VLM calls at runtime; prefer whatever + // the existing conf already had over overwriting it with an empty string. + if (vlm && !String(vlm.api_key || '').trim() && existing.vlm && String(existing.vlm.api_key || '').trim()) { + config.vlm = existing.vlm; + } + try { + fs.mkdirSync(path.dirname(confPath), { recursive: true }); + fs.writeFileSync(confPath, JSON.stringify(config, null, 2), 'utf8'); + return { ok: true, path: confPath, vlmKeyEmpty: !String(config.vlm.api_key || '').trim() }; + } catch (err) { + return { ok: false, error: String(err.message || err) }; + } + }, + + // Start the OpenViking server as a detached background process. + async 'loopx.startOvServer'({} = {}) { + try { + const child = spawn('openviking-server', [], { detached: true, stdio: 'ignore', windowsHide: true }); + child.unref(); + return { ok: true, pid: child.pid }; + } catch (err) { + return { ok: false, error: String(err.message || err) }; + } + }, + + // Point the `ov` CLI at the local server. + async 'loopx.ovConfigCli'({} = {}) { + const { code, stdout, stderr } = await runOvConfigCli(); + return { ok: code === 0, error: code === 0 ? null : (String(stderr || stdout).slice(-200) || `exit ${code}`) }; + }, + + // Self-heal the CLI connection whenever the env gate passes on installed + // binaries but the CLI never got (or lost) its ovcli.conf. Idempotent: a + // healthy CLI returns immediately; otherwise re-register and re-verify. + async 'loopx.ensureOvCli'({} = {}) { + if (await ovCliHealth()) return { ok: true, already: true }; + const conf = await runOvConfigCli(); + if (conf.code !== 0) { + return { ok: false, error: (String(conf.stderr || conf.stdout).slice(-200) || `exit ${conf.code}`) }; + } + const ok = await ovCliHealth(); + return { ok, error: ok ? null : 'ov CLI config registered but health check still failing' }; + }, + + // 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 refs = githubReferences(String(url)).refs; + const repoLabel = refs.length ? refs[0].repo : null; + const intake = await planIssueIntake({ argvPrefix, srcDir, projectDir, url, repoLabel }); + 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, AND the live open/closed state so the + // sheet can flag an issue that was already resolved before we start. + // (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 githubApiGetCached(`/repos/${refs[0].repo}/issues/${issue.number}`); + if (detail && typeof detail.body === 'string') issue.hasImages = bodyHasImages(detail.body); + if (detail) { + issue.state = detail.state || 'open'; + issue.closed = detail.state === 'closed'; + issue.stateReason = detail.state_reason || null; + } + } 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, + }; + }, + + // Remove the per-write "Owner approval gate" that loopx bootstrap hardcodes + // (coordination.requires_parent_approval includes "write"). Keeps publish + + // production-action gated. Idempotent registry surgery. + async 'loopx.grantWriteAuthority'({ projectDir = null, goalId } = {}) { + if (!goalId) throw new Error('loopx.grantWriteAuthority: goalId is required'); + const changed = clearWriteApprovalRequirement(projectDir, goalId); + return { ok: changed > 0, changed, message: changed > 0 ? 'write approval requirement cleared' : 'no change needed' }; + }, + + // 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 projection = projectGoalIssues((payload && payload.todos) || []); + return { + ok: result.code === 0, + ...projection, + error: result.code === 0 ? null : result.stderr.slice(0, 300), + }; + }, + + // Live re-check: which of the goal's still-open issue-todos are now closed on + // GitHub (maintainer resolved / merged) and therefore must NOT be re-fixed. + // Rate-limited twice over: a per-goal 10-min result cache plus the global + // GET cache / backoff in checkIssueStatus. Failures fail open (resolved=[]). + async 'loopx.liveIssueCheck'({ argvPrefix = null, projectDir = null, goalId } = {}) { + if (!goalId) throw new Error('loopx.liveIssueCheck: goalId is required'); + const hit = liveIssueCheckCache.get(goalId); + if (hit && Date.now() - hit.at < LIVE_ISSUE_CHECK_TTL_MS) { + return { ok: true, resolved: hit.resolved, cached: true }; + } + const { result, payload } = await runJson(argvPrefix, projectDir, ['todo', 'list', '--goal-id', goalId]); + const projection = projectGoalIssues((payload && payload.todos) || []); + // Rotating cursor: a >10-issue goal must not re-check the SAME first 10 + // forever while the tail stays a permanent blind spot — each cycle starts + // where the last one left off and wraps around, so every open issue is + // re-verified within (N/10) cycles. + const candidates = projection.issues.filter((issue) => { + if (issue.done) return false; + return /github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/(?:issues|pull)\/\d+/i.test(String(issue.url || '')); + }); + const prevResolved = (hit && Array.isArray(hit.resolved)) ? hit.resolved : []; + const resolvedUrls = new Set(prevResolved.map((r) => r.url)); + const pending = candidates.filter((issue) => !resolvedUrls.has(issue.url)); + const cursor = (hit && Number.isInteger(hit.cursor)) ? hit.cursor : 0; + const start = pending.length ? cursor % pending.length : 0; + const batch = pending.slice(start, start + LIVE_ISSUE_CHECK_MAX_ISSUES) + .concat(pending.slice(0, Math.max(0, start + LIVE_ISSUE_CHECK_MAX_ISSUES - pending.length))); + const resolved = [...prevResolved]; + for (const issue of batch) { + const m = String(issue.url || '').match(/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/(?:issues|pull)\/(\d+)/i); + if (!m) continue; + const st = await checkIssueStatus(`${m[1]}/${m[2]}`, Number(m[3])); + if (st.ok && !st.open) { + resolved.push({ + url: issue.url, + number: issue.number, + title: issue.title, + state: st.state, + stateReason: st.stateReason, + }); + } + } + liveIssueCheckCache.set(goalId, { + at: Date.now(), + resolved, + cursor: pending.length ? (start + batch.length) % pending.length : 0, + }); + saveGhState(); + return { ok: result.code === 0, resolved, cached: false }; + }, + + // Repository-memory indexing status: is OpenViking up, and did this repo's + // scope actually get seeded? Surfaces the silent-failure case (sync timed out + // on a large repo and the scope stayed empty) so the UI can warn instead of + // pretending memory is available. + async 'loopx.memoryStatus'({ repoLabel = null } = {}) { + const scopeRef = repoLabel ? repoMemoryScopeRef(repoLabel) : null; + const out = { ok: true, serverOk: false, resourceExists: false, scopeRef }; + try { + const health = await spawnLoopx({ argv: ['ov'] }, ['health'], { timeoutMs: 15000 }); + out.serverOk = health.code === 0; + } catch (_) { out.serverOk = false; } + if (scopeRef && out.serverOk) { + out.resourceExists = await ovResourceExists(scopeRef); + } + return out; + }, + + // Human reward feedback: record "this turn was useful / not useful" into + // loopx's run index + ACTIVE_GOAL_STATE.md via the serve-status reward API. + async 'loopx.rewardFeedback'({ argvPrefix = null, projectDir = null, goalId, reward = 'positive', decision = 'continue_route', reasonSummary = null } = {}) { + if (!goalId) throw new Error('loopx.rewardFeedback: goalId is required'); + if (!projectDir) throw new Error('loopx.rewardFeedback: projectDir is required'); + if (!['positive', 'negative', 'mixed', 'neutral'].includes(reward)) { + throw new Error(`loopx.rewardFeedback: bad reward "${reward}"`); + } + const generatedAt = await latestRunGeneratedAt(argvPrefix, projectDir, goalId); + if (!generatedAt) return { ok: false, error: 'no run history found for this goal yet' }; + const server = await ensureStatusServer(argvPrefix, projectDir); + const body = { + goal_id: goalId, + run_generated_at: generatedAt, + decision, + reward, + reason_summary: String(reasonSummary || (reward === 'positive' ? 'user marked this turn useful' : 'user marked this turn not useful')).slice(0, 300), + }; + const dry = await httpJson('POST', server.port, '/reward/dry-run', body); + if (dry.status !== 200 || !dry.payload || !dry.payload.preview_id) { + return { ok: false, error: (dry.payload && dry.payload.error) || `reward dry-run HTTP ${dry.status}` }; + } + const append = await httpJson('POST', server.port, '/reward/append', { + ...body, + preview_id: dry.payload.preview_id, + write_active_state_summary: true, + }); + if (append.status !== 200 || !append.payload || append.payload.appended !== true) { + return { ok: false, error: (append.payload && append.payload.error) || `reward append HTTP ${append.status}` }; + } + dbgWorker('reward:feedback', `goal=${goalId} reward=${reward} decision=${decision}`); + return { ok: true, appended: true, reward, decision }; + }, + + // 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); + // Per-file +/- counts feed the diff-first approval view ("改了哪些文件、 + // 每个文件加删了多少行") — the human must see WHAT changed before + // approving a PR, not just a prose summary. + const numstatOut = await gitRun(projectDir, ['diff', '--numstat', effective], 30000); + const numstat = String(numstatOut.stdout || '').split(/\r?\n/) + .map((line) => { + const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/); + if (!m) return null; + const added = m[1] === '-' ? 0 : Number(m[1]); + const deleted = m[2] === '-' ? 0 : Number(m[2]); + return { path: m[3], added, deleted }; + }) + .filter(Boolean); + // A single capped unified diff for the expandable "看差异" view. Diffs can + // be enormous (generated files, lockfiles); cap the bytes so a huge branch + // never round-trips megabytes through the RPC bridge. + let hunks = null; + try { + const diffOut = await gitRun(projectDir, ['diff', '--unified=3', effective], 30000); + const raw = String(diffOut.stdout || ''); + hunks = raw.length > 40000 ? `${raw.slice(0, 40000)}\n… (diff truncated)` : raw; + } catch (_) { hunks = null; } + return { + ok: true, + range, + files: String(names.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean), + stat: String(stat.stdout || '').trim() || null, + numstat, + hunks, + }; + }, + + // 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') }; + }, + + // Transition one todo's lifecycle status (open/done/blocked/deferred) — the + // per-issue pause/resume control. `reason` rides --reason for the + // blocked/deferred transitions; `note` is a general public-safe note. + async 'loopx.updateTodo'({ + argvPrefix = null, srcDir = null, projectDir = null, + goalId, todoId, status, note = null, reason = null, + } = {}) { + if (!goalId || !todoId || !status) throw new Error('loopx.updateTodo: goalId, todoId and status are required'); + const args = ['todo', 'update', '--goal-id', goalId, '--todo-id', todoId, '--status', status]; + if (note) args.push('--note', note); + if (reason) args.push('--reason', reason); + 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 update failed') }; + }, + + // Remove one todo (supersede) — the per-issue delete control. + async 'loopx.supersedeTodo'({ + argvPrefix = null, srcDir = null, projectDir = null, + goalId, todoId, reason = null, + } = {}) { + if (!goalId || !todoId) throw new Error('loopx.supersedeTodo: goalId and todoId are required'); + const args = ['todo', 'supersede', '--goal-id', goalId, '--todo-id', todoId]; + if (reason) args.push('--reason', reason); + 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 supersede failed') }; + }, + + // Append one GitHub issue as a new agent todo (fix_issue) — the per-goal + // "新增 issue" action. + async 'loopx.addIssueTodo'({ + argvPrefix = null, srcDir = null, projectDir = null, + goalId, text, repo = null, agentId = null, + } = {}) { + if (!goalId || !text) throw new Error('loopx.addIssueTodo: goalId and text are required'); + const args = [ + 'todo', 'add', '--goal-id', goalId, '--role', 'agent', + '--text', text, '--task-class', 'advancement_task', '--action-kind', 'fix_issue', + ]; + if (repo) args.push('--task-repository', `git:github.com/${repo}`); + if (agentId) args.push('--claimed-by', agentId); + const { result, payload } = await runJson(argvPrefix, projectDir, args, { srcDir }); + const ok = result.code === 0 && payload?.ok !== false; + return { ok, todoId: payload?.todo_id ?? null, error: ok ? null : (payload?.error || result.stderr.slice(0, 300) || 'todo add failed') }; + }, + + // List ALL open issues of a GitHub repository (public, anonymous API) — the + // source for the goal picker's issue checklist (selected + available). + async 'loopx.listRepoIssues'({ repo = null } = {}) { + if (!repo) throw new Error('loopx.listRepoIssues: repo is required'); + const fetched = await fetchOpenIssues(repo); + return { ok: true, repo, issues: fetched.issues, truncated: fetched.truncated }; + }, + + // 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 = []; + const skippedResolved = []; + let rewardMemory = null; + let repoMemorySync = null; + 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'); + } + // bootstrap hardcodes requires_parent_approval=["write",...] no + // matter what --write-scope says, so the pre-grant above does NOT + // actually un-gate code edits. Drop "write" explicitly (publish + + // production-action stay gated) so a fix never parks on a write + // approval the user already gave at intake. + clearWriteApprovalRequirement(workingDir, targetGoalId); + } + + // 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; + + // Reward memory + repository memory wiring is best-effort and never + // gates the fix: a provider hiccup degrades to the pre-memory path. + rewardMemory = await enableRewardMemory({ argvPrefix, srcDir, projectDir: workingDir, goalId: targetGoalId, agentId, repoLabel }); + // The full-repo OpenViking seed can take minutes on a big repo AND the + // server indexes in the background anyway — running it inline blocked + // intake for up to 4 minutes while the first (and only) memory query + // still came up empty. Fire-and-forget: the sync proceeds while the + // todos are written and the first turn starts; later turns (and the + // next goal on this repo) hit a warm index. + repoMemorySync = { ok: null, pending: true, scopeRef: repoLabel ? repoMemoryScopeRef(repoLabel) : null }; + syncRepoToOpenViking({ repoLabel, repoDir: workingDir }).then((sync) => { + dbgWorker('repoMemory:bgSync', `ok=${sync && sync.ok} scope=${sync && sync.scopeRef}`); + global.rpcEmit('repoMemorySync:done', { goalId: targetGoalId, ...sync }); + }).catch((err) => { + dbgWorker('repoMemory:bgSyncError', String(err && err.message || err)); + }); + + 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; an issue that was + // already closed upstream is skipped with a non-blocking notice. + const issue = issueList[0]; + const status = repoLabel ? await checkIssueStatus(repoLabel, issue.number) : null; + if (status && !status.open) { + skippedResolved.push({ ...issue, state: status.state, stateReason: status.stateReason }); + issueResults.push({ url: issue.url, ok: true, skippedResolved: true, state: status.state, stateReason: status.stateReason, written: [] }); + emit('resolved', { detail: issue.url, state: status.state, stateReason: status.stateReason }); + } else if (await hasOpenIssueTodo(argvPrefix, srcDir, workingDir, targetGoalId, issue.url)) { + skippedDuplicates.push(issue); + issueResults.push({ url: issue.url, ok: true, skippedDuplicate: true, written: [] }); + } else { + emit('plan', { current: 1, total: 1, detail: issue.url }); + failedStage = 'plan'; + const intake = await planIssueIntake({ argvPrefix, srcDir, projectDir: workingDir, url: issue.url, repoLabel }); + const result = intake.ok + ? await writePlannedTodos({ argvPrefix, srcDir, projectDir: workingDir, goalId: targetGoalId, agentId, intake }) + : intake; + issueResults.push({ url: issue.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]; + // Live status first: an issue closed upstream must not be re-fixed. + const status = repoLabel ? await checkIssueStatus(repoLabel, issue.number) : null; + if (status && !status.open) { + skippedResolved.push({ ...issue, state: status.state, stateReason: status.stateReason }); + written.push({ ok: true, skippedResolved: true, actionKind: 'fix_issue', url: issue.url, state: status.state, stateReason: status.stateReason }); + emit('resolved', { detail: issue.url, state: status.state, stateReason: status.stateReason }); + continue; + } + // 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 { + let response = await runJson(argvPrefix, workingDir, args, { srcDir, timeoutMs: 60000 }); + let ok = response.result.code === 0 && response.payload?.ok !== false; + // loopx can crash (Python traceback) AFTER actually writing the + // todo. One retry distinguishes a transient post-write crash from + // a real failure via already_exists, so the todo is never lost. + if (!ok) { + const retry = await runJson(argvPrefix, workingDir, args, { srcDir, timeoutMs: 60000 }); + if (retry.result.code === 0 && (retry.payload?.ok !== false || retry.payload?.already_exists)) { + response = retry; + ok = true; + } + } + 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, + skippedResolved: skippedResolved.length, + skippedResolvedIssues: skippedResolved, + rewardMemory, + repoMemorySync, + 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 = {}; + const needsWriteGrant = []; // goalId -> projectDir, for auto-heal below + 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; + // Auto-heal: goals bootstrapped before the fix still carry the + // hardcoded "write" parent-approval requirement — clear it so they + // stop parking on a write gate. Idempotent (no write if absent). + if (Array.isArray(coordination.requires_parent_approval) + && coordination.requires_parent_approval.includes('write')) { + needsWriteGrant.push({ goalId, dir }); + } + } + } + for (const { goalId, dir } of needsWriteGrant) { + clearWriteApprovalRequirement(dir, goalId); + } + + 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 }; + }, + + // A turn may have changed todo statuses; the resume-reconciliation cache must + // not feed the next turn a stale "从 #X 继续" note. ui.js calls this at turn end. + async 'loopx.invalidateResumeCache'() { + resumeReconCache = null; + return { ok: true }; + }, + + 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, + // Explicit scheduler execution context: newer loopx rejects the + // `--runtime-profile outer_controller` shortcut as an invalid context + // (missing host_surface/scheduler_owner/execution_mode → "repair scheduler + // context" / "stop until repaired"). The BitFun host runs the agent + // headlessly as the outer controller through a generic CLI surface. + '--host-surface', 'generic_cli', + '--scheduler-owner', 'outer_controller', + '--execution-mode', 'isolated_headless', + '--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, + // Same scheduler context as shouldRun — keep the two in lock-step. + '--host-surface', 'generic_cli', + '--scheduler-owner', 'outer_controller', + '--execution-mode', 'isolated_headless', + '--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.', + ); + // Stateless resume reconciliation (loopx philosophy: the registry/todos are + // the source of truth, not the chat session). Compute which issue-todos are + // already done (skip) vs still open (continue from the first), so a fresh + // agent session can resume exactly where the last turn left off. + let resume = null; + let resumeNote = ''; + const reconKey = `${goalId}@${projectDir}`; + if (resumeReconCache && resumeReconCache.key === reconKey && (Date.now() - resumeReconCache.at) < RESUME_RECON_TTL_MS) { + resume = resumeReconCache.resume; + resumeNote = resumeReconCache.resumeNote; + dbgWorker('turnPrompt:resumeCacheHit', reconKey); + } else { + try { + const issueList = await runJson(argvPrefix, projectDir, ['todo', 'list', '--goal-id', goalId], { srcDir, timeoutMs: 60000 }); + const projection = projectGoalIssues((issueList.payload && issueList.payload.todos) || []); + resume = projection; + if (projection.total > 0) { + const doneNums = projection.issues.filter((i) => i.done).map((i) => `#${i.number}`); + const openNums = projection.issues.filter((i) => !i.done).map((i) => `#${i.number}`); + const donePart = doneNums.length ? `已完成(跳过):${doneNums.join('、')}` : '暂无已完成'; + const openPart = openNums.length ? `未完成(从这里继续,按顺序处理):${openNums.join('、')}` : '全部已完成'; + resumeNote = `${donePart}。${openPart}。`; + } + resumeReconCache = { key: reconKey, at: Date.now(), resume, resumeNote }; + } catch (err) { + dbgWorker('turnPrompt:resumeError', String(err && err.message || err)); + } + } + const resumeBlock = resumeNote ? `\n\n本轮续跑对账(来自 loopx 注册表,非会话记忆):${resumeNote}\n` : ''; + return { + ok: true, + prompt: turnPreamble({ projectDir, goalId, agentId }) + resumeBlock + sanitized, + resume, + resumeNote, + }; + }, +}; 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/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx index 4920ffcef3..aaf401deb0 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx @@ -115,6 +115,21 @@ function BridgeHarness() { return <iframe ref={iframeRef} title="Market Lens test" />; } +function ScopedBridgeHarness({ + appId, + title, + runScope, +}: { + appId: string; + title: string; + runScope: { kind: 'active'; appId: string } | { kind: 'draft'; appId: string; draftId: string }; +}) { + const iframeRef = useRef<HTMLIFrameElement>(null); + const scopedApp = { ...app, id: appId, name: title } as MiniApp; + useMiniAppBridge(iframeRef, scopedApp, runScope, true); + return <iframe ref={iframeRef} title={title} />; +} + async function dispatchRpc( iframe: HTMLIFrameElement, id: number, @@ -274,4 +289,73 @@ describe('useMiniAppBridge floating Agent routing', () => { expect(mocks.agentEnsureSession.mock.calls[0][1].enableTools).toBeUndefined(); expect(mocks.agentRun.mock.calls[0][3].enableTools).toBeUndefined(); }); + + it('keeps Agent session ownership across a remount of the same runner scope', async () => { + const appId = 'remount-agent-app'; + await act(async () => { + root.render( + <ScopedBridgeHarness + appId={appId} + title="Remount runner" + runScope={{ kind: 'active', appId }} + />, + ); + }); + let iframe = container.querySelector('iframe') as HTMLIFrameElement; + await dispatchRpc(iframe, 1, 'agent.ensureSession', { + sessionName: 'Remount runner', + appDataWorkspace: 'chat', + }); + + await act(async () => { + root.render(<></>); + }); + await act(async () => { + root.render( + <ScopedBridgeHarness + appId={appId} + title="Remount runner" + runScope={{ kind: 'active', appId }} + />, + ); + }); + iframe = container.querySelector('iframe') as HTMLIFrameElement; + await dispatchRpc(iframe, 2, 'agent.run', { + sessionId: 'session-1', + prompt: 'Continue after remount', + }); + + expect(mocks.agentRun).toHaveBeenCalledTimes(1); + }); + + it('isolates Agent sessions between active and draft runners of the same app', async () => { + const appId = 'scope-isolation-app'; + await act(async () => { + root.render( + <> + <ScopedBridgeHarness + appId={appId} + title="Active runner" + runScope={{ kind: 'active', appId }} + /> + <ScopedBridgeHarness + appId={appId} + title="Draft runner" + runScope={{ kind: 'draft', appId, draftId: 'draft-1' }} + /> + </>, + ); + }); + const [activeIframe, draftIframe] = [...container.querySelectorAll('iframe')]; + await dispatchRpc(activeIframe, 1, 'agent.ensureSession', { + sessionName: 'Active runner', + appDataWorkspace: 'chat', + }); + await dispatchRpc(draftIframe, 2, 'agent.run', { + sessionId: 'session-1', + prompt: 'Must not cross runner scopes', + }); + + expect(mocks.agentRun).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts index be3bba68a7..1304b1568d 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -50,6 +50,36 @@ interface AiStreamPayload { let composerTokenSeq = 0; const log = createLogger('useMiniAppBridge'); +/** + * Module-level registry of MiniApp-owned agent session ids, keyed by runner + * scope. Installed apps and draft previews intentionally share an app id, but + * must never share Agent ownership. + * + * A hidden agent turn runs in the host while this hook owns its event routing. + * When the hook REMOUNTS mid-turn (tab switch, iframe reload), a per-hook + * `useRef` would reset to empty and silently drop the turn's remaining + * `agentic://*` events — most importantly `dialog-turn-completed`. The MiniApp + * then never learns the turn finished, so its own stall watchdog later cancels + * an already-completed turn. Keeping the set at module scope makes it survive + * remounts for the lifetime of the webview. + */ +const agentSessionIdsByRunner = new Map<string, Set<string>>(); + +function agentSessionRegistryKey(scope: MiniAppRunScope): string { + return scope.kind === 'draft' + ? `draft:${scope.appId}:${scope.draftId}` + : `active:${scope.appId}`; +} + +function agentSessionIdsFor(registryKey: string): Set<string> { + let set = agentSessionIdsByRunner.get(registryKey); + if (!set) { + set = new Set<string>(); + agentSessionIdsByRunner.set(registryKey, set); + } + return set; +} + export function useMiniAppBridge( iframeRef: RefObject<HTMLIFrameElement>, app: MiniApp, @@ -99,8 +129,16 @@ export function useMiniAppBridge( ]); // Hidden agent sessions started by this iframe; used to filter the global - // agentic:// event stream before forwarding events into the iframe. - const agentSessionIdsRef = useRef<Set<string>>(new Set()); + // agentic:// event stream before forwarding events into the iframe. Backed by + // a module-level map (see agentSessionIdsFor) so a mid-turn remount does not + // reset the set and drop the turn's completion event. + const sessionRegistryKey = agentSessionRegistryKey(runScope); + const sessionRegistryKeyRef = useRef(sessionRegistryKey); + const agentSessionIdsRef = useRef<Set<string>>(agentSessionIdsFor(sessionRegistryKey)); + if (sessionRegistryKeyRef.current !== sessionRegistryKey) { + sessionRegistryKeyRef.current = sessionRegistryKey; + agentSessionIdsRef.current = agentSessionIdsFor(sessionRegistryKey); + } // This runner's identity for bubble composer claims. const composerTokenRef = useRef<string>(''); @@ -770,6 +808,20 @@ export function useMiniAppBridge( }, '*', ); + if ( + eventName === 'dialog-turn-completed' + || eventName === 'dialog-turn-failed' + || eventName === 'dialog-turn-cancelled' + ) { + agentSessionIdsRef.current.delete(eventSessionId); + const registryKey = sessionRegistryKeyRef.current; + if ( + agentSessionIdsRef.current.size === 0 + && agentSessionIdsByRunner.get(registryKey) === agentSessionIdsRef.current + ) { + agentSessionIdsByRunner.delete(registryKey); + } + } }, ), ); 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,點擊左下角的「小應用」入口使用。" } }