From 0537f99f4d241569e9b5ff3f8552a810dc3b927e Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:19:39 +0000 Subject: [PATCH 1/5] Add direct Goose Agent Mode --- .githooks/pre-commit | 33 +- .github/workflows/desktop-build.yml | 4 +- .github/workflows/desktop-pr-build.yml | 4 +- .github/workflows/release.yml | 2 +- .github/workflows/rust-tests.yml | 4 +- README.md | 6 +- flake.lock | 6 +- flake.nix | 2 +- frontend/src-tauri/Cargo.lock | 2872 ++++++++++- frontend/src-tauri/Cargo.toml | 13 +- frontend/src-tauri/capabilities/default.json | 2 + frontend/src-tauri/src/agent.rs | 4300 +++++++++++++++++ frontend/src-tauri/src/lib.rs | 63 +- frontend/src-tauri/src/proxy.rs | 673 ++- frontend/src/billing/billingService.ts | 39 +- frontend/src/components/AccountMenu.tsx | 128 +- frontend/src/components/AgentMode.tsx | 2770 +++++++++++ .../src/components/BillingServiceProvider.tsx | 9 +- .../src/components/DeleteAccountDialog.tsx | 73 +- frontend/src/components/DeleteChatDialog.tsx | 8 +- .../components/GuestPaymentWarningDialog.tsx | 66 +- frontend/src/components/Sidebar.tsx | 93 +- frontend/src/components/VerificationModal.tsx | 61 +- .../components/apikeys/ProxyConfigSection.tsx | 9 +- frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/__root.tsx | 9 + frontend/src/routes/agent.tsx | 33 + .../src/services/agentAuthLifecycle.test.ts | 67 + frontend/src/services/agentAuthLifecycle.ts | 56 + frontend/src/services/agentModels.test.ts | 33 + frontend/src/services/agentModels.ts | 23 + .../src/services/agentOperationFence.test.ts | 111 + frontend/src/services/agentOperationFence.ts | 92 + frontend/src/services/agentRuntimeService.ts | 288 ++ frontend/src/services/agentTimeline.test.ts | 59 + frontend/src/services/agentTimeline.ts | 20 + frontend/src/services/proxyService.test.ts | 160 + frontend/src/services/proxyService.ts | 611 ++- 38 files changed, 12377 insertions(+), 446 deletions(-) create mode 100644 frontend/src-tauri/src/agent.rs create mode 100644 frontend/src/components/AgentMode.tsx create mode 100644 frontend/src/routes/agent.tsx create mode 100644 frontend/src/services/agentAuthLifecycle.test.ts create mode 100644 frontend/src/services/agentAuthLifecycle.ts create mode 100644 frontend/src/services/agentModels.test.ts create mode 100644 frontend/src/services/agentModels.ts create mode 100644 frontend/src/services/agentOperationFence.test.ts create mode 100644 frontend/src/services/agentOperationFence.ts create mode 100644 frontend/src/services/agentRuntimeService.ts create mode 100644 frontend/src/services/agentTimeline.test.ts create mode 100644 frontend/src/services/agentTimeline.ts create mode 100644 frontend/src/services/proxyService.test.ts diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3108f527..8f2f8e13 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,10 +2,38 @@ echo "Running pre-commit hook..." -# Navigate to frontend directory from repo root REPO_ROOT=$(git rev-parse --show-toplevel) -cd "$REPO_ROOT/frontend" || exit 1 +STAGED_FILES=$(git diff --cached --name-only) +NEEDS_TOOLCHAIN=0 + +if ! command -v bun >/dev/null 2>&1; then + NEEDS_TOOLCHAIN=1 +fi + +if echo "$STAGED_FILES" | grep -Eq '^frontend/src-tauri/.*\.(rs|toml|lock)$' \ + && ! command -v cargo >/dev/null 2>&1; then + NEEDS_TOOLCHAIN=1 +fi + +if [ "$NEEDS_TOOLCHAIN" -eq 1 ]; then + if [ "${MAPLE_HOOK_NIX_BOOTSTRAPPED:-0}" = "1" ]; then + echo "Error: Maple's Nix development shell did not provide the required tools." + exit 1 + fi + + if command -v nix >/dev/null 2>&1 && [ -f "$REPO_ROOT/flake.nix" ]; then + echo "Required tools are not on PATH; entering the Maple Nix development shell..." + exec nix develop "$REPO_ROOT" -c env MAPLE_HOOK_NIX_BOOTSTRAPPED=1 \ + "$REPO_ROOT/.githooks/pre-commit" + fi + echo "Error: This hook requires bun, plus cargo for staged Rust changes." + echo "Install the missing tools or run the commit from 'nix develop'." + exit 1 +fi + +# Navigate to frontend directory from repo root +cd "$REPO_ROOT/frontend" || exit 1 # Run prettier check echo "Checking code formatting with Prettier..." @@ -35,7 +63,6 @@ if ! bun run test; then fi # Run Rust unit tests only when relevant Rust/Tauri files are staged -STAGED_FILES=$(git diff --cached --name-only) if echo "$STAGED_FILES" | grep -Eq '^frontend/src-tauri/.*\.(rs|toml|lock)$'; then echo "Running Rust unit tests..." cd "$REPO_ROOT/frontend/src-tauri" || exit 1 diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index e06c8b39..2b905c23 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -8,7 +8,7 @@ permissions: on: push: - branches: [ master ] + branches: [master] jobs: build-macos: @@ -239,7 +239,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable with: - toolchain: 1.88.0 + toolchain: 1.94.1 - name: Install sccache shell: bash diff --git a/.github/workflows/desktop-pr-build.yml b/.github/workflows/desktop-pr-build.yml index 494678bd..193e61dd 100644 --- a/.github/workflows/desktop-pr-build.yml +++ b/.github/workflows/desktop-pr-build.yml @@ -5,7 +5,7 @@ permissions: on: pull_request: - branches: [ master ] + branches: [master] jobs: build-macos: @@ -138,7 +138,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable with: - toolchain: 1.88.0 + toolchain: 1.94.1 - name: Install sccache shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 236301f6..d509e7bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -191,7 +191,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable with: - toolchain: 1.88.0 + toolchain: 1.94.1 - name: Install sccache shell: bash diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 41a427e0..648260d3 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -5,9 +5,9 @@ permissions: on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: rust-tests: diff --git a/README.md b/README.md index d6b95e47..2d2772c6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,11 @@ rustup target add aarch64-apple-darwin x86_64-apple-darwin ./setup-hooks.sh ``` -This will configure git to use the project's pre-commit hooks, which run `bun run build` before each commit. +This configures Git to use the project's pre-commit hook. Managed +`opensecret-workspaces` checkouts enable the same hook automatically. The hook +checks formatting, builds the frontend, runs frontend tests, and runs Rust tests +when Tauri files are staged. If Bun or Cargo is not already available and Nix +is installed, the hook enters this repository's Nix development shell. ## Development diff --git a/flake.lock b/flake.lock index d338308a..48e18cab 100644 --- a/flake.lock +++ b/flake.lock @@ -62,11 +62,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1767926800, - "narHash": "sha256-x0n73J6ufD/EhDlVdcoAmF0OQHZ+b0a2cKDc8RZyt+o=", + "lastModified": 1783663825, + "narHash": "sha256-TTrNVoFdEw8j0Hn+shP1KAsimTmszTWBIIUliTaAuY0=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "499e9eed88ff9494b6604205b42847e847dfeb91", + "rev": "072adf9b18936c1062c48123a9b77891d5968f2c", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 67e5ae16..629edcbf 100644 --- a/flake.nix +++ b/flake.nix @@ -30,7 +30,7 @@ versions = { bun = "1.3.5"; - rust = "1.88.0"; + rust = "1.94.1"; jdk = "21"; xcode = "26.5"; android = { diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 937928d6..f7a0fca4 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -52,6 +52,71 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-client-protocol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "schemars 1.0.5", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5ca63f112bd2459bcaf9eda0683b9ba95fc3b5e5fdd9036ca941c6a09345b1" +dependencies = [ + "quote", + "syn 2.0.108", +] + +[[package]] +name = "agent-client-protocol-http" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ff55efe7a6bb92a5d0226f6055c2f277b7591060d5095c7764f445289ebda3" +dependencies = [ + "agent-client-protocol", + "async-stream", + "axum", + "futures", + "serde_json", + "tokio", + "tower-http 0.7.0", + "tracing", + "uuid", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.0.5", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "ahash" version = "0.7.8" @@ -63,6 +128,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -87,6 +166,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -165,9 +250,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -178,6 +263,29 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -247,6 +355,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.13.3" @@ -399,6 +519,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -441,6 +570,7 @@ checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", "axum-macros", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -459,8 +589,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -563,6 +695,20 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -572,6 +718,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -603,6 +758,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" version = "1.5.7" @@ -647,6 +808,26 @@ dependencies = [ "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 = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -686,6 +867,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.24.0" @@ -732,6 +919,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "camino" version = "1.2.1" @@ -761,7 +958,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -833,7 +1030,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[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]] @@ -843,7 +1051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -851,9 +1059,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -863,6 +1071,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -896,7 +1114,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", "zeroize", ] @@ -941,6 +1159,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -950,6 +1177,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -966,6 +1199,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1001,16 +1254,50 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1074,6 +1361,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1083,6 +1379,30 @@ 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 = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1092,6 +1412,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum 0.27.2", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1120,6 +1451,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1155,6 +1495,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -1164,7 +1513,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec 1.15.1", ] @@ -1210,7 +1559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -1231,19 +1580,29 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] -name = "darling_core" -version = "0.21.3" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", @@ -1253,17 +1612,55 @@ dependencies = [ "syn 2.0.108", ] +[[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.108", +] + [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.108", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -1327,6 +1724,37 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.108", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1342,10 +1770,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.108", + "unicode-xid", ] [[package]] @@ -1354,12 +1784,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1436,6 +1876,15 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1444,7 +1893,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", @@ -1515,7 +1964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1527,6 +1976,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -1536,7 +1988,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -1549,6 +2001,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.6" @@ -1642,6 +2103,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "euclid" version = "0.20.14" @@ -1683,6 +2171,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1751,6 +2250,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.5" @@ -1761,12 +2266,40 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1824,6 +2357,35 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1838,9 +2400,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1853,36 +2415,60 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec 1.15.1", +] + [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1899,9 +2485,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1910,21 +2496,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1934,7 +2520,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2080,11 +2665,23 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + [[package]] name = "ghash" version = "0.5.1" @@ -2095,6 +2692,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2180,6 +2787,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2192,36 +2812,219 @@ dependencies = [ ] [[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +name = "goose" +version = "1.42.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" dependencies = [ - "ff", - "rand_core 0.6.4", + "agent-client-protocol", + "agent-client-protocol-http", + "agent-client-protocol-schema", + "anyhow", + "arboard", + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "blake3", + "chrono", + "clap", + "dirs", + "etcetera 0.11.0", + "fs-err", + "fs2", + "futures", + "gethostname", + "goose-acp-macros", + "goose-download-manager", + "goose-providers", + "goose-sdk-types", + "icu_calendar", + "icu_locale", + "ignore", + "image", + "include_dir", + "indexmap 2.12.0", + "indoc", + "jsonschema", + "jsonwebtoken", + "libc", + "lru", + "minijinja", + "nanoid", + "oauth2", + "once_cell", + "pastey", + "process-wrap", + "pulldown-cmark", + "rand 0.10.2", + "rayon", + "regex", + "reqwest 0.13.2", + "rmcp", + "schemars 1.0.5", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "serde_yaml", + "sha2 0.11.0", + "shell-words", + "shellexpand", + "sqlx", + "strum 0.28.0", "subtle", + "sys-info", + "tempfile", + "thiserror 2.0.18", + "tiktoken-rs", + "tokio", + "tokio-cron-scheduler", + "tokio-stream", + "tokio-util", + "tower-http 0.7.0", + "tracing", + "tracing-appender", + "tracing-futures", + "tracing-subscriber", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-kotlin-ng", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-swift", + "tree-sitter-typescript", + "unicode-normalization", + "url", + "urlencoding", + "utoipa", + "uuid", + "v_htmlescape", + "webbrowser", + "which", + "winapi", ] [[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +name = "goose-acp-macros" +version = "1.42.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] + "quote", + "syn 2.0.108", +] + +[[package]] +name = "goose-download-manager" +version = "0.1.0-alpha.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +dependencies = [ + "anyhow", + "once_cell", + "reqwest 0.13.2", + "serde", + "tokio", + "tracing", + "utoipa", +] + +[[package]] +name = "goose-provider-types" +version = "0.1.0-alpha.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "once_cell", + "rand 0.10.2", + "regex", + "reqwest 0.13.2", + "rmcp", + "serde", + "serde_json", + "strum 0.28.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "unicode-normalization", + "utoipa", + "uuid", +] + +[[package]] +name = "goose-providers" +version = "0.1.0-alpha.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "chrono", + "futures", + "goose-provider-types", + "include_dir", + "reqwest 0.13.2", + "rmcp", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "utoipa", +] + +[[package]] +name = "goose-sdk-types" +version = "0.1.0-alpha.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "schemars 1.0.5", + "serde", + "serde_json", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[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" @@ -2290,7 +3093,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", ] [[package]] @@ -2299,11 +3102,36 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] [[package]] name = "hdrhistogram" @@ -2354,7 +3182,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -2419,6 +3256,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.7.0" @@ -2519,6 +3365,20 @@ dependencies = [ "png 0.17.16", ] +[[package]] +name = "icu_calendar" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_locale_core", + "icu_provider", + "tinystr", + "zerovec", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -2532,6 +3392,20 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locale" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.1.1" @@ -2540,6 +3414,7 @@ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -2593,6 +3468,8 @@ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2627,6 +3504,56 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif", + "jpeg-decoder", + "num-traits", + "png 0.17.16", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2650,6 +3577,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -2747,19 +3683,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "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.18", + "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.108", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[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.108", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2770,6 +3755,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.95" @@ -2804,6 +3795,50 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50180452e7808015fe083eae3efcf1ec98b89b45dd8cc204f7b4a6b7b81ea675" +dependencies = [ + "ahash 0.8.12", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "getrandom 0.2.16", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2832,6 +3867,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libappindicator" @@ -2859,9 +3897,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2910,17 +3948,34 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.11.0" +name = "libsqlite3-sys" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2957,6 +4012,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" + [[package]] name = "lru-slab" version = "0.1.2" @@ -2972,6 +4033,7 @@ dependencies = [ "base64 0.22.1", "dirs", "futures-util", + "goose", "hound", "keyring", "log", @@ -2986,13 +4048,15 @@ dependencies = [ "rand_distr", "regex", "reqwest 0.13.2", + "rmcp", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tauri", "tauri-build", "tauri-plugin", "tauri-plugin-deep-link", + "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-opener", @@ -3001,19 +4065,21 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", + "tokio-util", "unicode-normalization", ] [[package]] name = "maple-proxy" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2b14734d1be21edaecd68fee9fcfbcf6ea85f958e33a43155fff595d35140c" +checksum = "29ac117843d081e34ed1bbaf386e4975c2c93af4649668fe86282737007ae922" dependencies = [ "anyhow", "async-stream", "axum", "clap", + "dashmap", "dotenvy", "futures", "http", @@ -3022,7 +4088,7 @@ dependencies = [ "serde_json", "tokio", "tower", - "tower-http", + "tower-http 0.6.8", "tracing", "tracing-subscriber", ] @@ -3070,7 +4136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -3079,6 +4145,12 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "memoffset" version = "0.9.1" @@ -3088,12 +4160,38 @@ dependencies = [ "autocfg", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[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 = "minijinja" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3118,9 +4216,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -3144,10 +4242,19 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.61.2", ] +[[package]] +name = "nanoid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873" +dependencies = [ + "rand 0.9.4", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -3188,7 +4295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.10.0", - "jni-sys", + "jni-sys 0.3.0", "log", "ndk-sys", "num_enum", @@ -3196,13 +4303,19 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] [[package]] @@ -3224,6 +4337,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -3243,6 +4368,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3253,6 +4392,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec 1.15.1", + "zeroize", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -3268,6 +4429,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -3277,6 +4449,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3318,6 +4511,25 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.16", + "http", + "rand 0.8.6", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3354,6 +4566,7 @@ dependencies = [ "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -3590,9 +4803,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -3643,8 +4856,8 @@ dependencies = [ "ring", "serde", "serde_json", - "sha2", - "thiserror 2.0.17", + "sha2 0.10.9", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -3759,7 +4972,7 @@ checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890" dependencies = [ "flate2", "pkg-config", - "sha2", + "sha2 0.10.9", "tar", "ureq", ] @@ -3787,9 +5000,15 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -3799,7 +5018,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3856,6 +5075,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3877,6 +5102,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3892,6 +5127,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -3899,7 +5143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] @@ -3910,7 +5154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -3920,7 +5164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -3930,12 +5174,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.108", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -3988,6 +5241,17 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -4063,7 +5327,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4075,7 +5339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4113,6 +5377,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4208,6 +5474,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap 2.12.0", + "nix 0.31.3", + "tokio", + "tracing", + "windows 0.62.2", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4228,6 +5514,27 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.10.0", + "memchr", + "unicase", +] + [[package]] name = "quick-xml" version = "0.38.3" @@ -4251,7 +5558,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -4273,7 +5580,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4308,6 +5615,12 @@ 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 = "radium" version = "0.7.0" @@ -4335,6 +5648,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4373,6 +5697,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.4.3" @@ -4438,7 +5768,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4461,11 +5791,28 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "referencing" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +dependencies = [ + "ahash 0.8.12", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.0", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" -version = "1.12.2" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -4486,9 +5833,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" @@ -4533,7 +5880,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", @@ -4551,7 +5898,10 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -4564,6 +5914,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -4572,12 +5923,13 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", @@ -4597,27 +5949,51 @@ dependencies = [ ] [[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.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rkyv" -version = "0.7.45" +name = "rfd" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "bitvec", - "bytecheck", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "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.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", "bytes", "hashbrown 0.12.3", "ptr_meta", @@ -4639,6 +6015,71 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f542f74cf247da16f19bbc87e298cd201e912314f4083e88cdd671f44f5fcb53" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "oauth2", + "pastey", + "pin-project-lite", + "process-wrap", + "reqwest 0.13.2", + "rmcp-macros", + "schemars 1.0.5", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "rmcp-macros" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.108", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -4747,7 +6188,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -4816,7 +6257,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -4841,8 +6282,10 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1317c3bf3e7df961da95b0a56a172a02abead31276215a0497241a7624b487ce" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.0.5", "serde", "serde_json", ] @@ -4859,6 +6302,18 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "schemars_derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f760a6150d45dd66ec044983c124595ae76912e77ed0b44124cb3e415cce5d9" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.108", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4932,7 +6387,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash", @@ -5009,6 +6464,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap 2.12.0", "itoa", "memchr", "ryu", @@ -5070,11 +6526,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.1" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -5089,16 +6546,29 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.108", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5130,6 +6600,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5137,8 +6618,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5150,6 +6642,21 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5171,7 +6678,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -5181,12 +6688,34 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +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 = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -5204,6 +6733,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smallvec" @@ -5213,12 +6745,12 @@ checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5280,6 +6812,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -5290,6 +6831,211 @@ dependencies = [ "der", ] +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.12.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec 1.15.1", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.108", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.108", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec 1.15.1", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec 1.15.1", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "sse-stream" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5300,7 +7046,13 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "string_cache" @@ -5310,7 +7062,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -5321,17 +7073,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5349,6 +7154,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -5391,6 +7202,16 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -5450,7 +7271,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -5466,7 +7287,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5523,7 +7344,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -5547,14 +7368,14 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tray-icon", "url", "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -5595,10 +7416,10 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.108", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "url", "uuid", @@ -5649,13 +7470,31 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", "windows-registry", "windows-result 0.3.4", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.5.1" @@ -5675,7 +7514,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 1.1.0+spec-1.1.0", "url", ] @@ -5698,7 +7537,7 @@ dependencies = [ "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -5718,9 +7557,9 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -5739,7 +7578,7 @@ dependencies = [ "sys-locale", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -5751,7 +7590,7 @@ dependencies = [ "serde", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -5764,7 +7603,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", "zbus", @@ -5795,7 +7634,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "url", @@ -5813,7 +7652,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2 0.6.4", "objc2-ui-kit", "objc2-web-kit", @@ -5821,11 +7660,11 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -5836,7 +7675,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2 0.6.4", "objc2-app-kit", @@ -5850,7 +7689,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5872,7 +7711,7 @@ dependencies = [ "json-patch", "log", "memchr", - "phf", + "phf 0.13.1", "plist", "proc-macro2", "quote", @@ -5884,7 +7723,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 1.1.0+spec-1.1.0", "url", "urlpattern", @@ -5936,11 +7775,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -5956,9 +7795,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -5974,6 +7813,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiktoken-rs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "fancy-regex", + "lazy_static", + "regex", + "rustc-hash", +] + [[package]] name = "time" version = "0.3.47" @@ -6023,6 +7877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6043,9 +7898,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -6058,11 +7913,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-cron-scheduler" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a" +dependencies = [ + "chrono", + "chrono-tz", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -6079,6 +7950,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -6087,6 +7981,7 @@ checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -6238,19 +8133,39 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "http", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -6265,9 +8180,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -6275,11 +8190,24 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -6288,63 +8216,199 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec 1.15.1", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "tree-sitter" +version = "0.26.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-ng" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" dependencies = [ - "once_cell", - "valuable", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "tree-sitter-rust" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" dependencies = [ - "log", - "once_cell", - "tracing-core", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-subscriber" -version = "0.3.20" +name = "tree-sitter-swift" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec 1.15.1", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", + "cc", + "tree-sitter-language", ] [[package]] -name = "tray-icon" -version = "0.23.1" +name = "tree-sitter-typescript" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2 0.6.4", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.17", - "windows-sys 0.61.2", + "cc", + "tree-sitter-language", ] [[package]] @@ -6353,6 +8417,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "type1-encoding-parser" version = "0.1.0" @@ -6370,9 +8451,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -6426,6 +8507,24 @@ 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-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -6441,22 +8540,40 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -6505,6 +8622,12 @@ dependencies = [ "serde", ] +[[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" @@ -6541,18 +8664,67 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap 2.12.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "v_escape-base" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1212fce830b75af194b578e55b3db9049f2c8c45f58d397fb25602fdb50fb3d" + +[[package]] +name = "v_htmlescape" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "befb3d53c9e3ec641417685896cbc8cc5bd264d6a2e190c56aaef1af24740d99" +dependencies = [ + "v_escape-base", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6583,6 +8755,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" @@ -6637,6 +8815,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -6744,12 +8928,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -6820,7 +9020,7 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -6843,8 +9043,8 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ - "thiserror 2.0.17", - "windows", + "thiserror 2.0.18", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -6854,6 +9054,25 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[package]] +name = "which" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6906,11 +9125,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -6922,6 +9153,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -6956,7 +9196,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7003,6 +9254,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -7059,6 +9320,15 @@ 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" @@ -7110,6 +9380,21 @@ dependencies = [ "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" @@ -7152,6 +9437,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -7167,6 +9461,12 @@ 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" @@ -7185,6 +9485,12 @@ 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" @@ -7203,6 +9509,12 @@ 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" @@ -7233,6 +9545,12 @@ 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" @@ -7251,6 +9569,12 @@ 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" @@ -7269,6 +9593,12 @@ 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" @@ -7287,6 +9617,12 @@ 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" @@ -7363,7 +9699,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2 0.6.4", @@ -7375,15 +9711,15 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -7419,6 +9755,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -7507,7 +9860,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", @@ -7626,6 +9979,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -7654,6 +10008,34 @@ dependencies = [ "memchr", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "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", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 7f41a9ec..e85690bd 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -6,7 +6,7 @@ authors = ["tony@trymaple.ai"] license = "MIT" repository = "https://github.com/OpenSecretCloud/Maple" edition = "2021" -rust-version = "1.88.0" +rust-version = "1.94.1" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -36,7 +36,7 @@ tauri-plugin-os = "2.3.2" tauri-plugin-sign-in-with-apple = "1.0.2" tokio = { version = "1.0", features = ["net", "sync", "rt-multi-thread", "macros", "time"] } once_cell = "1.18.0" -maple-proxy = "0.1.8" +maple-proxy = "0.1.10" tauri-plugin-fs = "2.5.1" anyhow = "1.0" axum = "0.8" @@ -70,6 +70,15 @@ futures-util = "0.3" dirs = "6.0" sha2 = "0.10" +[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] +# Pin Goose to an exact official upstream commit. Keep this as a git dependency +# instead of a submodule so ordinary Maple checkouts do not need the full Goose +# history. +goose = { git = "https://github.com/aaif-goose/goose.git", rev = "b7eb1e9735833a7bf12ab92994a788fbc770f218", package = "goose", default-features = false } +rmcp = { version = "=1.4.0", default-features = false } +tauri-plugin-dialog = "2.7.1" +tokio-util = "0.7" + [target.'cfg(target_os = "ios")'.dependencies] # TTS dependencies (Supertonic) - iOS # We build ONNX Runtime 1.22.2 from source for iOS (see scripts/build-ios-onnxruntime.sh) diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 8695013b..a3b95019 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -5,9 +5,11 @@ "windows": [ "main" ], + "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default", "updater:default", + "dialog:default", "fs:default", { "identifier": "fs:allow-read-file", diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs new file mode 100644 index 00000000..f7a55b90 --- /dev/null +++ b/frontend/src-tauri/src/agent.rs @@ -0,0 +1,4300 @@ +use crate::proxy; +use futures_util::StreamExt; +use goose::agents::{ + Agent, AgentConfig as GooseAgentConfig, AgentEvent, ExtensionConfig, GoosePlatform, + SessionConfig, +}; +use goose::config::{ + ConfigError, GooseMode, PermissionManager, DEFAULT_EXTENSION_DESCRIPTION, + DEFAULT_EXTENSION_TIMEOUT, +}; +use goose::conversation::message::{ + ActionRequiredData, Message, MessageContent, SystemNotificationContent, SystemNotificationType, +}; +use goose::conversation::Conversation; +use goose::execution::manager::AgentManager; +use goose::permission::permission_confirmation::PrincipalType; +use goose::permission::{Permission, PermissionConfirmation}; +use goose::session::session_manager::{Session, SessionType}; +use goose::session::SessionManager; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::sync::{oneshot, Mutex}; +use tokio_util::sync::CancellationToken; + +const DEFAULT_AGENT_MODEL: &str = "glm-5-2"; +const LEGACY_AGENT_DEFAULT_MODEL: &str = "auto:powerful"; +const DEFAULT_GOOSE_MODE: &str = "smart_approve"; +const AGENT_EVENT_NAME: &str = "agent-event"; +const MAPLE_DEVELOPER_TOOLS: [&str; 5] = ["write", "edit", "shell", "tree", "read_image"]; +const RUN_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const DEFAULT_AGENT_SESSION_TITLE: &str = "New agent session"; +const MAX_AGENT_SESSION_TITLE_CHARS: usize = 80; +const MAX_AGENT_ERROR_CHARS: usize = 1_200; +static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfig { + pub default_project_root: Option, + #[serde(default = "default_agent_model")] + pub default_model: String, +} + +fn default_agent_model() -> String { + DEFAULT_AGENT_MODEL.to_string() +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + default_project_root: None, + default_model: default_agent_model(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStartRequest { + pub project_root: Option, + pub model: Option, + pub mode: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRuntimeStatus { + pub running: bool, + pub project_root: Option, + pub model: Option, + pub mode: Option, + pub active_runs: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentProjectRoot { + pub path: String, + pub name: String, + pub last_used_ms: u128, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCreateSessionRequest { + pub project_root: Option, + pub title: Option, + pub model: Option, + pub mode: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSendMessageRequest { + pub session_id: String, + pub text: String, + pub model: Option, + pub mode: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentPermissionResponse { + pub session_id: String, + pub request_id: String, + pub decision: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRunResponse { + pub run_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionSummary { + pub id: String, + pub title: String, + pub project_root: String, + pub created_ms: i64, + pub updated_ms: i64, + pub message_count: usize, + pub model: Option, + pub mode: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionDetail { + pub session: AgentSessionSummary, + pub timeline: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTimelineItem { + pub id: String, + pub item_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + pub created_ms: u128, + pub merge: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentEventEnvelope { + pub event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +struct ActiveAgentRun { + token: CancellationToken, + session_id: String, + task_handle: tauri::async_runtime::JoinHandle<()>, +} + +type PendingPermissionKey = (String, String); +type PendingPermissions = Arc>>; + +struct AgentRuntime { + agent_manager: Arc, + session_manager: Arc, + active_runs: HashMap, + project_root: PathBuf, + model: String, + mode: String, + account_scope: String, +} + +impl AgentRuntime { + fn status(&self) -> AgentRuntimeStatus { + AgentRuntimeStatus { + running: true, + project_root: Some(path_string(&self.project_root)), + model: Some(self.model.clone()), + mode: Some(self.mode.clone()), + active_runs: self + .active_runs + .iter() + .map(|(run_id, run)| (run.session_id.clone(), run_id.clone())) + .collect(), + } + } +} + +pub struct AgentRuntimeState { + inner: Arc>>, + runtime_lifecycle: Arc>, + account_generations: Arc>>, + session_lifecycle: Arc>, + pending_permissions: PendingPermissions, + live_timelines: LiveTimelines, +} + +type LiveTimelines = Arc>>; + +#[derive(Clone, Debug, PartialEq)] +enum LiveTimeline { + /// The current turn is still emitting events, so this is the authoritative + /// presentation suffix from its real-user boundary onward. + Streaming(Vec), + /// Goose finished the turn. Most terminal messages are persisted, but its + /// synthetic provider errors and notices can be live-only. Resolve that + /// distinction against the conversation already loaded for the next view. + Completed(LiveMessageCandidate), + /// A Maple/Goose task failure is never part of provider history. Keep only + /// its bounded user-facing error row between views and retries. + Failed(Vec), +} + +impl LiveTimeline { + fn items(&self) -> &[AgentTimelineItem] { + match self { + Self::Streaming(items) => items, + Self::Completed(candidate) => &candidate.items, + Self::Failed(items) => items, + } + } + + fn items_mut(&mut self) -> &mut Vec { + match self { + Self::Streaming(items) => items, + Self::Completed(candidate) => &mut candidate.items, + Self::Failed(items) => items, + } + } +} + +impl AgentRuntimeState { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(None)), + runtime_lifecycle: Arc::new(Mutex::new(())), + account_generations: Arc::new(Mutex::new(HashMap::new())), + session_lifecycle: Arc::new(Mutex::new(())), + pending_permissions: Arc::new(Mutex::new(HashMap::new())), + live_timelines: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +fn account_scope(user_id: &str) -> Result { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Err("Agent Mode requires a signed-in account".to_string()); + } + let digest = Sha256::digest(user_id.as_bytes()); + Ok(format!("{digest:x}")) +} + +fn ensure_runtime_account(runtime: &AgentRuntime, account_scope: &str) -> Result<(), String> { + ensure_account_scope(&runtime.account_scope, account_scope) +} + +fn ensure_account_scope(current_scope: &str, requested_scope: &str) -> Result<(), String> { + if current_scope == requested_scope { + Ok(()) + } else { + Err("Agent runtime belongs to a different signed-in account".to_string()) + } +} + +async fn account_generation(state: &AgentRuntimeState, account_scope: &str) -> u64 { + *state + .account_generations + .lock() + .await + .get(account_scope) + .unwrap_or(&0) +} + +async fn ensure_account_generation( + state: &AgentRuntimeState, + account_scope: &str, + expected: u64, +) -> Result<(), String> { + if account_generation(state, account_scope).await == expected { + Ok(()) + } else { + Err("Agent Mode data changed while this operation was waiting".to_string()) + } +} + +async fn advance_account_generation(state: &AgentRuntimeState, account_scope: &str) -> u64 { + let mut generations = state.account_generations.lock().await; + let generation = generations.entry(account_scope.to_string()).or_default(); + *generation = generation + .checked_add(1) + .expect("Agent Mode exhausted its account operation generation"); + *generation +} + +fn next_run_id() -> String { + let sequence = NEXT_RUN_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .expect("Agent Mode exhausted its run ID sequence"); + format!("run_{}_{sequence}", unix_ms()) +} + +fn session_title_from_prompt(prompt: &str) -> String { + let collapsed = prompt.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= MAX_AGENT_SESSION_TITLE_CHARS { + return collapsed; + } + + let mut title = collapsed + .chars() + .take(MAX_AGENT_SESSION_TITLE_CHARS - 1) + .collect::(); + title.truncate(title.trim_end().len()); + title.push('…'); + title +} + +fn should_name_session_from_prompt(session: &Session) -> bool { + session.message_count == 0 + && !session.user_set_name + && session.name == DEFAULT_AGENT_SESSION_TITLE +} + +async fn pending_permissions_for_sessions( + pending_permissions: &PendingPermissions, + session_ids: &[String], +) -> Vec<(String, String)> { + let pending = pending_permissions.lock().await; + pending + .keys() + .filter(|(session_id, _)| session_ids.contains(session_id)) + .map(|(session_id, request_id)| (request_id.clone(), session_id.clone())) + .collect() +} + +async fn cancel_pending_permissions_for_sessions( + agent_manager: &Arc, + pending_permissions: &PendingPermissions, + session_ids: &[String], +) -> Vec<(String, String)> { + let mut cancelled = Vec::new(); + for (request_id, session_id) in + pending_permissions_for_sessions(pending_permissions, session_ids).await + { + match agent_manager.get_or_create_agent(session_id.clone()).await { + Ok(agent) => { + agent + .handle_confirmation( + request_id.clone(), + PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission: Permission::Cancel, + }, + ) + .await; + let mut pending = pending_permissions.lock().await; + pending.remove(&(session_id.clone(), request_id.clone())); + cancelled.push((request_id, session_id)); + } + Err(error) => { + log::warn!( + "Failed to cancel pending Agent Mode permission for session {session_id}: {error}" + ); + } + } + } + cancelled +} + +async fn register_pending_permission( + pending_permissions: &PendingPermissions, + request_id: &str, + session_id: &str, + cancel_token: &CancellationToken, +) -> bool { + if cancel_token.is_cancelled() { + return false; + } + let mut pending = pending_permissions.lock().await; + let key = (session_id.to_string(), request_id.to_string()); + pending.insert(key.clone(), ()); + if cancel_token.is_cancelled() { + pending.remove(&key); + false + } else { + true + } +} + +async fn stop_runtime_for_user(state: &AgentRuntimeState, user_id: &str) -> Result<(), String> { + let account_scope = account_scope(user_id)?; + stop_runtime_inner(state, Some(&account_scope)).await +} + +async fn stop_runtime_inner( + state: &AgentRuntimeState, + requested_scope: Option<&str>, +) -> Result<(), String> { + let (agent_manager, active_runs) = { + let mut runtime = state.inner.lock().await; + let Some(current) = runtime.as_mut() else { + return Ok(()); + }; + if let Some(account_scope) = requested_scope { + ensure_runtime_account(current, account_scope)?; + } + ( + Arc::clone(¤t.agent_manager), + std::mem::take(&mut current.active_runs), + ) + }; + + let session_ids = active_runs + .values() + .map(|run| run.session_id.clone()) + .collect::>(); + let mut task_handles = Vec::with_capacity(active_runs.len()); + for (_, active_run) in active_runs { + // Cancel first so an ActionRequired event racing this snapshot will + // take the immediate-cancel path in register_pending_permission. + active_run.token.cancel(); + task_handles.push(active_run.task_handle); + } + let _ = cancel_pending_permissions_for_sessions( + &agent_manager, + &state.pending_permissions, + &session_ids, + ) + .await; + + join_agent_tasks(task_handles, RUN_SHUTDOWN_TIMEOUT).await; + + state.pending_permissions.lock().await.clear(); + state.live_timelines.lock().await.clear(); + *state.inner.lock().await = None; + Ok(()) +} + +async fn join_agent_tasks( + mut task_handles: Vec>, + graceful_timeout: std::time::Duration, +) { + let graceful = futures_util::future::join_all(task_handles.iter_mut()); + if tokio::time::timeout(graceful_timeout, graceful) + .await + .is_err() + { + for task_handle in &task_handles { + task_handle.abort(); + } + // Once abort is requested, join every task without another timeout. + // Dropping a still-running JoinHandle detaches it and could leave an OS + // child or old-account event source alive after a new runtime starts. + let _ = futures_util::future::join_all(task_handles).await; + } +} + +pub async fn shutdown_agent_runtime(app_handle: &AppHandle) -> Result<(), String> { + let state = app_handle.state::(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + stop_runtime_inner(&state, None).await +} + +#[tauri::command] +pub async fn agent_get_runtime_status( + state: State<'_, AgentRuntimeState>, + user_id: String, +) -> Result { + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + let account_scope = account_scope(&user_id)?; + let runtime = state.inner.lock().await; + if let Some(current) = runtime.as_ref() { + ensure_runtime_account(current, &account_scope)?; + return Ok(current.status()); + } + Ok(stopped_status()) +} + +#[tauri::command] +pub async fn agent_start_runtime( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + proxy_state: State<'_, proxy::ProxyState>, + user_id: String, + request: Option, +) -> Result { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + start_runtime_for_user(app_handle, &state, proxy_state, user_id, request).await +} + +async fn start_runtime_for_user( + app_handle: AppHandle, + state: &AgentRuntimeState, + proxy_state: State<'_, proxy::ProxyState>, + user_id: String, + request: Option, +) -> Result { + let account_scope = account_scope(&user_id)?; + { + let runtime = state.inner.lock().await; + if let Some(current) = runtime.as_ref() { + ensure_runtime_account(current, &account_scope)?; + return Ok(current.status()); + } + } + + let agent_config = load_agent_config_inner(&app_handle, &user_id).unwrap_or_default(); + let request = request.unwrap_or(AgentStartRequest { + project_root: None, + model: None, + mode: None, + }); + + let proxy_status = proxy::ensure_proxy_running(app_handle.clone(), proxy_state).await?; + let proxy_config = proxy_status.config; + let proxy_host = if proxy_config.host == "0.0.0.0" { + "127.0.0.1".to_string() + } else { + proxy_config.host.clone() + }; + let maple_proxy_base_url = format!("http://{}:{}", proxy_host, proxy_config.port); + + let project_root = resolve_project_root(request.project_root.as_deref(), &agent_config) + .map_err(|e| format!("Failed to resolve Agent Mode project root: {e}"))?; + let model = request.model.unwrap_or(agent_config.default_model); + let mode = request + .mode + .unwrap_or_else(|| DEFAULT_GOOSE_MODE.to_string()); + + let config_dir = agent_config_dir(&app_handle, &user_id).map_err(|e| e.to_string())?; + let goose_path_root = config_dir.join("goose"); + fs::create_dir_all(goose_path_root.join("data")) + .map_err(|e| format!("Failed to create Goose data dir: {e}"))?; + fs::create_dir_all(goose_path_root.join("config")) + .map_err(|e| format!("Failed to create Goose config dir: {e}"))?; + + configure_embedded_goose( + &agent_root_dir(&app_handle) + .map_err(|e| e.to_string())? + .join("goose-runtime"), + &model, + &mode, + &maple_proxy_base_url, + )?; + + let session_manager = Arc::new(SessionManager::new(goose_path_root.join("data"))); + let permission_manager = Arc::new(PermissionManager::new(goose_path_root.join("config"))); + let goose_mode = parse_goose_mode(&mode); + let goose_config = GooseAgentConfig::new( + Arc::clone(&session_manager), + permission_manager, + None, + goose_mode, + true, + GoosePlatform::GooseDesktop, + ) + .with_use_login_shell_path(true); + let agent_manager = Arc::new( + AgentManager::new(goose_config, None) + .await + .map_err(|e| format!("Failed to create Goose agent manager: {e}"))?, + ); + + let runtime = AgentRuntime { + agent_manager, + session_manager, + active_runs: HashMap::new(), + project_root: project_root.clone(), + model: model.clone(), + mode: mode.clone(), + account_scope, + }; + let status = runtime.status(); + + { + let mut guard = state.inner.lock().await; + *guard = Some(runtime); + } + + let _ = save_recent_project_root_inner(&app_handle, &user_id, &project_root); + let mut next_config = load_agent_config_inner(&app_handle, &user_id).unwrap_or_default(); + next_config.default_project_root = Some(path_string(&project_root)); + next_config.default_model = model; + let _ = save_agent_config_inner(&app_handle, &user_id, &next_config); + + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "runtimeStatus".to_string(), + session_id: None, + run_id: None, + item: None, + status: Some(status.clone()), + session: None, + message: None, + }, + ); + + Ok(status) +} + +#[tauri::command] +pub async fn agent_stop_runtime( + state: State<'_, AgentRuntimeState>, + user_id: String, +) -> Result { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + stop_runtime_for_user(&state, &user_id).await?; + Ok(stopped_status()) +} + +#[tauri::command] +pub async fn agent_restart_runtime( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + proxy_state: State<'_, proxy::ProxyState>, + user_id: String, + request: Option, +) -> Result { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + stop_runtime_for_user(&state, &user_id).await?; + start_runtime_for_user(app_handle, &state, proxy_state, user_id, request).await +} + +#[tauri::command] +pub async fn agent_clear_user_data( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, +) -> Result<(), String> { + let requested_scope = account_scope(&user_id)?; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + advance_account_generation(&state, &requested_scope).await; + let is_running_account = { + let runtime = state.inner.lock().await; + runtime + .as_ref() + .is_some_and(|current| current.account_scope == requested_scope) + }; + if is_running_account { + stop_runtime_for_user(&state, &user_id).await?; + } + + let account_dir = + account_config_dir_path(&app_handle, &user_id).map_err(|error| error.to_string())?; + match fs::remove_dir_all(account_dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("Failed to clear Agent Mode data: {error}")), + } + Ok(()) +} + +#[tauri::command] +pub async fn agent_clear_user_history( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, +) -> Result<(), String> { + let requested_scope = account_scope(&user_id)?; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + advance_account_generation(&state, &requested_scope).await; + let is_running_account = { + let runtime = state.inner.lock().await; + runtime + .as_ref() + .is_some_and(|current| current.account_scope == requested_scope) + }; + if is_running_account { + stop_runtime_for_user(&state, &user_id).await?; + } + + let account_dir = + account_config_dir_path(&app_handle, &user_id).map_err(|error| error.to_string())?; + clear_agent_history(&account_dir) + .map_err(|error| format!("Failed to clear Agent Mode history: {error}")) +} + +#[tauri::command] +pub async fn agent_load_config( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, +) -> Result { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn agent_save_config( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + config: AgentConfig, +) -> Result<(), String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + save_agent_config_inner(&app_handle, &user_id, &config).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn agent_list_recent_project_roots( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + load_recent_project_roots_inner(&app_handle, &user_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn agent_save_recent_project_root( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + path: String, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let project_root = normalize_project_root(Path::new(&path))?; + save_recent_project_root_inner(&app_handle, &user_id, &project_root).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn agent_create_session( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + request: Option, +) -> Result { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let request = request.unwrap_or(AgentCreateSessionRequest { + project_root: None, + title: None, + model: None, + mode: None, + }); + let (agent_manager, session_manager, runtime_project_root, runtime_model, runtime_mode) = { + let runtime = state.inner.lock().await; + let current = runtime + .as_ref() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &account_scope)?; + ( + Arc::clone(¤t.agent_manager), + Arc::clone(¤t.session_manager), + current.project_root.clone(), + current.model.clone(), + current.mode.clone(), + ) + }; + + let root = match request.project_root.as_deref() { + Some(path) if !path.trim().is_empty() => normalize_project_root(Path::new(path))?, + _ => runtime_project_root, + }; + let title = request + .title + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_AGENT_SESSION_TITLE.to_string()); + let mode = request.mode.unwrap_or(runtime_mode); + let model = request.model.unwrap_or(runtime_model); + let session = session_manager + .create_session( + root.clone(), + title, + SessionType::User, + parse_goose_mode(&mode), + ) + .await + .map_err(|e| format!("Failed to create Goose session: {e}"))?; + + configure_session_agent(&agent_manager, &session, &model, &mode).await?; + let summary = session_summary(&session); + let _ = save_recent_project_root_inner(&app_handle, &user_id, &root); + let detail = AgentSessionDetail { + session: summary.clone(), + timeline: Vec::new(), + }; + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "sessionCreated".to_string(), + session_id: Some(summary.id.clone()), + run_id: None, + item: None, + status: None, + session: Some(summary), + message: None, + }, + ); + Ok(detail) +} + +#[tauri::command] +pub async fn agent_list_sessions( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + project_root: Option, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let (session_manager, filter_root) = { + let runtime = state.inner.lock().await; + let session_manager = match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &account_scope)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&app_handle, &user_id)?, + }; + let filter_root = project_root + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|path| normalize_project_root(Path::new(path))) + .transpose()?; + (session_manager, filter_root) + }; + + let mut sessions = session_manager + .list_all_sessions() + .await + .map_err(|e| format!("Failed to list Goose sessions: {e}"))? + .into_iter() + .filter(|session| { + if let Some(root) = filter_root.as_ref() { + session.working_dir == *root + } else { + true + } + }) + .map(|session| session_summary(&session)) + .collect::>(); + sessions.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); + Ok(sessions) +} + +#[tauri::command] +pub async fn agent_load_session( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + session_id: String, +) -> Result { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let session_manager = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &account_scope)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&app_handle, &user_id)?, + } + }; + let session = session_manager + .get_session(&session_id, true) + .await + .map_err(|e| format!("Failed to load Goose session: {e}"))?; + let conversation = session + .conversation + .as_ref() + .ok_or_else(|| "Goose session history was not loaded".to_string())?; + let timeline = conversation_to_timeline_items(conversation); + let timeline = + overlay_live_timeline(&state.live_timelines, &session_id, conversation, timeline).await; + + Ok(AgentSessionDetail { + session: session_summary(&session), + timeline, + }) +} + +#[tauri::command] +pub async fn agent_delete_session( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + session_id: String, +) -> Result<(), String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let session_id = session_id.trim().to_string(); + if session_id.is_empty() { + return Err("Agent session ID cannot be empty".to_string()); + } + + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + let (agent_manager, session_manager) = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &account_scope)?; + if has_active_session_run(¤t.active_runs, &session_id) { + return Err("Stop the running agent before deleting this chat".to_string()); + } + ( + Some(Arc::clone(¤t.agent_manager)), + Arc::clone(¤t.session_manager), + ) + } + None => (None, account_session_manager(&app_handle, &user_id)?), + } + }; + + delete_persisted_agent_session( + session_manager.as_ref(), + &state.pending_permissions, + &state.live_timelines, + &session_id, + ) + .await?; + if let Some(agent_manager) = agent_manager { + if let Err(error) = agent_manager.remove_session_if_loaded(&session_id).await { + log::warn!( + "Deleted Goose session {session_id}, but failed to unload its agent: {error}" + ); + } + } + + Ok(()) +} + +async fn delete_persisted_agent_session( + session_manager: &SessionManager, + pending_permissions: &PendingPermissions, + live_timelines: &LiveTimelines, + session_id: &str, +) -> Result<(), String> { + session_manager + .get_session(session_id, false) + .await + .map_err(|e| format!("Failed to find Goose session {session_id}: {e}"))?; + session_manager + .delete_session(session_id) + .await + .map_err(|e| format!("Failed to delete Goose session {session_id}: {e}"))?; + + live_timelines.lock().await.remove(session_id); + pending_permissions + .lock() + .await + .retain(|(pending_session_id, _), _| pending_session_id != session_id); + + Ok(()) +} + +struct AgentTurnSnapshot { + conversation: Conversation, + autogenerated_title: Option, + live_timeline: Option, +} + +async fn rollback_cancelled_agent_turn( + session_manager: &SessionManager, + live_timelines: &LiveTimelines, + session_id: &str, + snapshot: &AgentTurnSnapshot, +) -> Result<(), String> { + session_manager + .replace_conversation(session_id, &snapshot.conversation) + .await + .map_err(|error| format!("Failed to restore conversation after cancellation: {error}"))?; + + if let Some(title) = snapshot.autogenerated_title.as_ref() { + let session = session_manager + .get_session(session_id, false) + .await + .map_err(|error| format!("Failed to inspect cancelled Agent session: {error}"))?; + if !session.user_set_name { + session_manager + .update(session_id) + .system_generated_name(title.clone()) + .apply() + .await + .map_err(|error| { + format!("Failed to restore cancelled Agent session title: {error}") + })?; + } + } + + // HistoryReplaced can clear the optimistic user boundary before later + // current-turn events arrive, so restore the exact pre-turn map entry + // instead of trying to identify and truncate a suffix. + let mut timelines = live_timelines.lock().await; + match snapshot.live_timeline.as_ref() { + Some(items) => { + timelines.insert(session_id.to_string(), items.clone()); + } + None => { + timelines.remove(session_id); + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn agent_send_message( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + request: AgentSendMessageRequest, +) -> Result { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let text = request.text.trim().to_string(); + if text.is_empty() { + return Err("Prompt cannot be empty".to_string()); + } + + let session_lifecycle_guard = state.session_lifecycle.lock().await; + let run_id = next_run_id(); + let cancel_token = CancellationToken::new(); + let prompt_title = session_title_from_prompt(&text); + let user_message = Message::user().with_text(text).with_generated_id(); + let (agent_manager, session_manager, model, mode) = { + let runtime = state.inner.lock().await; + let current = runtime + .as_ref() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &account_scope)?; + ( + Arc::clone(¤t.agent_manager), + Arc::clone(¤t.session_manager), + request + .model + .clone() + .unwrap_or_else(|| current.model.clone()), + request.mode.clone().unwrap_or_else(|| current.mode.clone()), + ) + }; + + let user_item = message_to_timeline_items(&user_message, false) + .into_iter() + .next() + .ok_or_else(|| "Failed to create user timeline item".to_string())?; + let live_timelines = Arc::clone(&state.live_timelines); + + // Claim the session before changing its title, provider, mode, or + // extensions. A duplicate send must not mutate an Agent that is already + // serving another run. + agent_manager + .try_register_cancel_token(&request.session_id, cancel_token.clone()) + .await + .map_err(|e| format!("Agent session is already running: {e}"))?; + + let setup_result: Result<(Arc, AgentTurnSnapshot), String> = async { + let mut session = session_manager + .get_session(&request.session_id, true) + .await + .map_err(|e| format!("Failed to load Goose session: {e}"))?; + let should_restore_autogenerated_title = should_name_session_from_prompt(&session); + // Cancellation must be able to reverse Goose compaction or recovery + // that rewrites history during this turn. Move the loaded conversation + // into the snapshot to avoid cloning large persisted image payloads. + let turn_snapshot = AgentTurnSnapshot { + conversation: session.conversation.take().unwrap_or_default(), + autogenerated_title: should_restore_autogenerated_title.then(|| session.name.clone()), + live_timeline: live_timelines.lock().await.get(&session.id).cloned(), + }; + if should_restore_autogenerated_title { + session_manager + .update(&session.id) + .system_generated_name(prompt_title) + .apply() + .await + .map_err(|e| format!("Failed to name Agent session: {e}"))?; + session = session_manager + .get_session(&session.id, false) + .await + .map_err(|e| format!("Failed to load named Goose session: {e}"))?; + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "sessionUpdated".to_string(), + session_id: Some(session.id.clone()), + run_id: Some(run_id.clone()), + item: None, + status: None, + session: Some(session_summary(&session)), + message: None, + }, + ); + } + let agent = configure_session_agent(&agent_manager, &session, &model, &mode).await?; + Ok((agent, turn_snapshot)) + } + .await; + let (agent, task_turn_snapshot) = match setup_result { + Ok(setup) => setup, + Err(error) => { + agent_manager + .unregister_cancel_token(&request.session_id) + .await; + return Err(error); + } + }; + + let app_handle_for_task = app_handle.clone(); + let state_inner = Arc::clone(&state.inner); + let session_lifecycle = Arc::clone(&state.session_lifecycle); + let pending_permissions = Arc::clone(&state.pending_permissions); + let session_id = request.session_id.clone(); + let task_run_id = run_id.clone(); + let task_agent_manager = Arc::clone(&agent_manager); + let task_session_manager = Arc::clone(&session_manager); + let task_user_message = user_message.clone(); + let task_cancel_token = cancel_token.clone(); + let (start_tx, start_rx) = oneshot::channel(); + let task = tauri::async_runtime::spawn(async move { + let should_run = tokio::select! { + biased; + _ = task_cancel_token.cancelled() => false, + start = start_rx => start.is_ok(), + }; + let result = if should_run { + run_agent_prompt(AgentPromptRun { + app_handle: app_handle_for_task.clone(), + agent, + session_manager: Arc::clone(&task_session_manager), + live_timelines: live_timelines.clone(), + session_id: session_id.clone(), + run_id: task_run_id.clone(), + user_message: task_user_message, + cancel_token: task_cancel_token.clone(), + pending_permissions, + }) + .await + } else { + Ok(AgentPromptOutcome::default()) + }; + + // Keep deletion serialized until every terminal write and event for + // this run has completed. The active-run entry stays visible while + // the cleanup is in progress, so deletion continues to reject it. + let _session_lifecycle_guard = session_lifecycle.lock().await; + let result = if task_cancel_token.is_cancelled() { + rollback_cancelled_agent_turn( + task_session_manager.as_ref(), + &live_timelines, + &session_id, + &task_turn_snapshot, + ) + .await + .map(|_| AgentPromptOutcome::default()) + } else { + result + }; + task_agent_manager + .unregister_cancel_token(&session_id) + .await; + if !task_cancel_token.is_cancelled() { + if let Ok(outcome) = &result { + let mut timelines = live_timelines.lock().await; + apply_successful_prompt_outcome(&mut timelines, &session_id, outcome); + } + } + + let (status, message) = match result { + Ok(_) if task_cancel_token.is_cancelled() => ("cancelled", None), + Ok(_) => ("completed", None), + Err(error) => ("failed", Some(error)), + }; + if let Some(error) = message.as_ref() { + let item = error_item(error.clone()); + { + let mut timelines = live_timelines.lock().await; + apply_failed_prompt_outcome(&mut timelines, &session_id, item.clone()); + } + emit_agent_event( + &app_handle_for_task, + AgentEventEnvelope { + event_type: "error".to_string(), + session_id: Some(session_id.clone()), + run_id: Some(task_run_id.clone()), + item: Some(item), + status: None, + session: None, + message: None, + }, + ); + } + emit_agent_event( + &app_handle_for_task, + AgentEventEnvelope { + event_type: "runFinished".to_string(), + session_id: Some(session_id), + run_id: Some(task_run_id.clone()), + item: None, + status: None, + session: None, + message: Some(status.to_string()), + }, + ); + // Remove the stored JoinHandle only after the final externally visible + // side effect. Stop may otherwise miss this task and return while its + // runFinished event is still pending. + let mut runtime = state_inner.lock().await; + if let Some(current) = runtime.as_mut() { + current.active_runs.remove(&task_run_id); + } + }); + + let mut task = Some(task); + let insertion_error = { + let mut runtime = state.inner.lock().await; + match runtime.as_mut() { + None => Some("Agent runtime is not running".to_string()), + Some(current) => match ensure_runtime_account(current, &account_scope) { + Err(error) => Some(error), + Ok(()) => { + current.active_runs.insert( + run_id.clone(), + ActiveAgentRun { + token: cancel_token.clone(), + session_id: request.session_id.clone(), + task_handle: task.take().expect("task handle must be available"), + }, + ); + None + } + }, + } + }; + if let Some(error) = insertion_error { + let task = task.expect("failed insertion must retain task handle"); + task.abort(); + let _ = task.await; + agent_manager + .unregister_cancel_token(&request.session_id) + .await; + return Err(error); + } + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "runStarted".to_string(), + session_id: Some(request.session_id.clone()), + run_id: Some(run_id.clone()), + item: None, + status: None, + session: None, + message: None, + }, + ); + + record_and_emit_timeline_item( + &app_handle, + &state.live_timelines, + &request.session_id, + &run_id, + user_item.clone(), + ) + .await; + let _ = start_tx.send(()); + // Keep the session claimed until the optimistic timeline item and start + // signal are ordered. A cancellation cleanup must not finish and then be + // followed by this send path re-appending the cancelled prompt. + drop(session_lifecycle_guard); + + Ok(AgentRunResponse { run_id }) +} + +#[tauri::command] +pub async fn agent_cancel_run( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + run_id: String, +) -> Result<(), String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let (agent_manager, session_id, cancel_token) = { + let runtime = state.inner.lock().await; + let Some(current) = runtime.as_ref() else { + return Ok(()); + }; + ensure_runtime_account(current, &account_scope)?; + let Some(active_run) = current.active_runs.get(&run_id) else { + return Ok(()); + }; + ( + Arc::clone(¤t.agent_manager), + active_run.session_id.clone(), + active_run.token.clone(), + ) + }; + cancel_token.cancel(); + let cancelled_permissions = cancel_pending_permissions_for_sessions( + &agent_manager, + &state.pending_permissions, + std::slice::from_ref(&session_id), + ) + .await; + for (request_id, session_id) in cancelled_permissions { + if let Some(item) = update_live_permission_status( + &state.live_timelines, + &session_id, + &request_id, + "cancelled", + ) + .await + { + emit_timeline_item(&app_handle, &session_id, &run_id, item); + } + } + Ok(()) +} + +#[tauri::command] +pub async fn agent_permission_respond( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + response: AgentPermissionResponse, +) -> Result<(), String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let (agent_manager, session_id) = { + let runtime = state.inner.lock().await; + let current = runtime + .as_ref() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &account_scope)?; + let session_id = response.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("Agent permission response requires a session ID".to_string()); + } + let key = (session_id.clone(), response.request_id.clone()); + if !state.pending_permissions.lock().await.contains_key(&key) { + return Err(format!( + "No pending Agent Mode permission request found for {} in session {}", + response.request_id, session_id + )); + } + (Arc::clone(¤t.agent_manager), session_id) + }; + let agent = agent_manager + .get_or_create_agent(session_id.clone()) + .await + .map_err(|e| format!("Failed to resolve Goose agent for permission response: {e}"))?; + agent + .handle_confirmation( + response.request_id.clone(), + PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission: permission_from_decision(&response.decision)?, + }, + ) + .await; + if let Some(item) = update_live_permission_status( + &state.live_timelines, + &session_id, + &response.request_id, + &response.decision, + ) + .await + { + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "timelineItem".to_string(), + session_id: Some(session_id.clone()), + run_id: None, + item: Some(item), + status: None, + session: None, + message: None, + }, + ); + } + state + .pending_permissions + .lock() + .await + .remove(&(session_id, response.request_id)); + Ok(()) +} + +struct AgentPromptRun { + app_handle: AppHandle, + agent: Arc, + session_manager: Arc, + live_timelines: LiveTimelines, + session_id: String, + run_id: String, + user_message: Message, + cancel_token: CancellationToken, + pending_permissions: PendingPermissions, +} + +#[derive(Default)] +struct AgentPromptOutcome { + terminal_message: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct LiveMessageCandidate { + id: Option, + role: String, + created: i64, + items: Vec, +} + +fn apply_successful_prompt_outcome( + timelines: &mut HashMap, + session_id: &str, + outcome: &AgentPromptOutcome, +) { + match outcome.terminal_message.as_ref() { + Some(candidate) => { + timelines.insert( + session_id.to_string(), + LiveTimeline::Completed(candidate.clone()), + ); + } + None => { + timelines.remove(session_id); + } + } +} + +fn apply_failed_prompt_outcome( + timelines: &mut HashMap, + session_id: &str, + item: AgentTimelineItem, +) { + timelines.insert(session_id.to_string(), LiveTimeline::Failed(vec![item])); +} + +async fn run_agent_prompt(run: AgentPromptRun) -> Result { + let AgentPromptRun { + app_handle, + agent, + session_manager, + live_timelines, + session_id, + run_id, + user_message, + cancel_token, + pending_permissions, + } = run; + let mut terminal_message = None; + let session_config = SessionConfig { + id: session_id.clone(), + schedule_id: None, + max_turns: None, + retry_config: None, + }; + let mut stream = agent + .reply(user_message, session_config, Some(cancel_token.clone())) + .await + .map_err(|e| format!("Goose reply failed: {e}"))?; + let updated_session = session_manager + .get_session(&session_id, false) + .await + .map_err(|e| format!("Failed to load updated Goose session: {e}"))?; + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "sessionUpdated".to_string(), + session_id: Some(session_id.clone()), + run_id: Some(run_id.clone()), + item: None, + status: None, + session: Some(session_summary(&updated_session)), + message: None, + }, + ); + + while let Some(event) = stream.next().await { + match event { + Ok(AgentEvent::Message(message)) => { + let mut items = message_to_timeline_items(&message, true); + for item in &mut items { + if let Some(request_id) = pending_permission_request_id(item) { + if !register_pending_permission( + &pending_permissions, + &request_id, + &session_id, + &cancel_token, + ) + .await + { + agent + .handle_confirmation( + request_id, + PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission: Permission::Cancel, + }, + ) + .await; + item.status = Some("cancelled".to_string()); + } + } + } + if !items.is_empty() { + terminal_message = Some(update_live_message_candidate( + terminal_message, + &message, + &items, + )); + } + for item in items { + record_and_emit_timeline_item( + &app_handle, + &live_timelines, + &session_id, + &run_id, + item, + ) + .await; + } + } + // Usage ledgers remain in Goose's persisted messages for context + // accounting, but Agent Mode does not render ephemeral token rows. + Ok(AgentEvent::Usage(_) | AgentEvent::MessageUsage { .. }) => {} + // Developer/MCP notifications are transport diagnostics. Tool + // requests, results, permissions, and failures arrive as messages + // and form the stable user-facing timeline. + Ok(AgentEvent::McpNotification(_)) => {} + Ok(AgentEvent::HistoryReplaced(conversation)) => { + terminal_message = None; + reseed_live_timeline_after_history_replaced( + &live_timelines, + &session_id, + &conversation, + ) + .await; + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "historyReplaced".to_string(), + session_id: Some(session_id.clone()), + run_id: Some(run_id.clone()), + item: None, + status: None, + session: None, + message: None, + }, + ); + } + Err(error) => { + return Err(format!("Goose stream failed: {error}")); + } + } + if cancel_token.is_cancelled() { + break; + } + } + + Ok(AgentPromptOutcome { terminal_message }) +} + +fn live_message_candidate(message: &Message, items: &[AgentTimelineItem]) -> LiveMessageCandidate { + LiveMessageCandidate { + id: message.id.clone(), + role: message_role(message), + created: message.created, + items: coalesce_timeline_items(items.to_vec()), + } +} + +fn update_live_message_candidate( + current: Option, + message: &Message, + items: &[AgentTimelineItem], +) -> LiveMessageCandidate { + let role = message_role(message); + // Provider stream chunks have a stable ID. Id-less Goose messages are + // complete logical events and may share the same second-resolution + // timestamp, so combining them would conflate a reply with a later notice. + let Some(mut current) = current.filter(|current| { + current.id.is_some() + && current.id == message.id + && current.role == role + && current.items.iter().all(|item| item.item_type != "system") + && items.iter().all(|item| item.item_type != "system") + }) else { + return live_message_candidate(message, items); + }; + + for item in items { + current.items = merge_timeline_item(current.items, item.clone()); + } + current +} + +fn timeline_item_matches( + live: &AgentTimelineItem, + persisted: &AgentTimelineItem, + match_id: bool, +) -> bool { + (!match_id || live.id == persisted.id) + && live.item_type == persisted.item_type + && live.role == persisted.role + && live.title == persisted.title + && live.text == persisted.text + && live.status == persisted.status + && live.input == persisted.input + && live.output == persisted.output +} + +fn terminal_message_is_persisted( + conversation: &Conversation, + candidate: &LiveMessageCandidate, +) -> bool { + let messages = conversation.messages(); + let current_turn_start = messages + .iter() + .rposition(|message| { + let role = message_role(message); + is_real_user_message(message, &role) + }) + .unwrap_or(0); + let turn_messages = &messages[current_turn_start..]; + if let Some(id) = candidate.id.as_deref() { + let mut persisted_items = Vec::new(); + for message in turn_messages.iter().filter(|message| { + message_role(message) == candidate.role && message.id.as_deref() == Some(id) + }) { + for item in message_to_timeline_items(message, true) { + persisted_items = merge_timeline_item(persisted_items, item); + } + } + return timeline_projection_matches(&candidate.items, &persisted_items, true); + } + + turn_messages + .iter() + .filter(|message| { + message_role(message) == candidate.role && message.created == candidate.created + }) + .any(|message| { + let persisted_items = coalesce_timeline_items(message_to_timeline_items(message, true)); + timeline_projection_matches(&candidate.items, &persisted_items, false) + }) +} + +fn timeline_projection_matches( + live: &[AgentTimelineItem], + persisted: &[AgentTimelineItem], + match_id: bool, +) -> bool { + live.len() == persisted.len() + && live + .iter() + .zip(persisted) + .all(|(live, persisted)| timeline_item_matches(live, persisted, match_id)) +} + +fn bounded_timeline_text(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let bounded = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{bounded}…") + } else { + bounded + } +} + +fn pending_permission_request_id(item: &AgentTimelineItem) -> Option { + if item.item_type == "permission" { + return item + .id + .strip_prefix("permission-") + .filter(|request_id| !request_id.is_empty()) + .map(ToString::to_string); + } + None +} + +async fn configure_session_agent( + agent_manager: &Arc, + session: &Session, + model: &str, + mode: &str, +) -> Result, String> { + let agent = agent_manager + .get_or_create_agent(session.id.clone()) + .await + .map_err(|e| format!("Failed to get Goose agent for session {}: {e}", session.id))?; + let provider = goose::providers::create_with_working_dir( + "openai", + Vec::new(), + session.working_dir.clone(), + ) + .await + .map_err(|e| format!("Failed to create Goose OpenAI provider: {e}"))?; + let model_config = goose::model_config::model_config_from_user_config("openai", model) + .map_err(|e| format!("Failed to configure Goose model {model}: {e}"))?; + agent + .update_provider(provider, model_config, &session.id) + .await + .map_err(|e| format!("Failed to update Goose provider: {e}"))?; + agent + .update_goose_mode(parse_goose_mode(mode), &session.id) + .await + .map_err(|e| format!("Failed to update Goose mode: {e}"))?; + let developer = ExtensionConfig::Builtin { + name: "developer".to_string(), + description: DEFAULT_EXTENSION_DESCRIPTION.to_string(), + display_name: Some("Developer".to_string()), + timeout: Some(DEFAULT_EXTENSION_TIMEOUT), + bundled: Some(true), + available_tools: MAPLE_DEVELOPER_TOOLS + .iter() + .map(|tool| tool.to_string()) + .collect(), + }; + agent + .add_extension(developer, &session.id) + .await + .map_err(|e| format!("Failed to enable Goose developer tools: {e}"))?; + Ok(agent) +} + +#[derive(Default)] +struct ConversationTimelineProjectionState { + surfaced_thinking_in_inference: bool, +} + +/// Project a stored Goose conversation into Maple's presentation timeline. +/// +/// Goose deliberately repeats reasoning blocks on each split tool-request +/// message. That replay belongs in the provider history, but it is not a second +/// user-visible thought. Keep this normalization local to a single conversation +/// so concurrent Agent sessions cannot affect one another and the +/// persisted/provider-facing history remains byte-for-byte unchanged. +fn conversation_to_timeline_items(conversation: &Conversation) -> Vec { + let mut state = ConversationTimelineProjectionState::default(); + let mut items = Vec::new(); + let messages = conversation.messages(); + + for (index, message) in messages.iter().enumerate() { + let role = message_role(message); + let assistant = role == "assistant"; + let inference_ends = assistant && message.metadata.usage.is_some(); + + // A real user message starts a new user turn. Tool responses are + // intentionally chain-neutral because Goose interleaves them between + // split requests from the same turn. + if is_real_user_message(message, &role) { + state.surfaced_thinking_in_inference = false; + } + + // Match Goose's own session presentation contract: agent-only grind, + // retry, goal, and other internal messages stay in provider history but + // never become user-facing Maple timeline rows. + if !message.is_user_visible() { + if inference_ends { + state.surfaced_thinking_in_inference = false; + } + continue; + } + + let mut thinking = message_thinking_projection(message); + let has_tool_request = message.content.iter().any(|content| { + matches!( + content, + MessageContent::ToolRequest(_) | MessageContent::FrontendToolRequest(_) + ) + }); + + // Goose intentionally copies reasoning onto every persisted split + // tool-request message for provider history. Its live AgentEvent stream + // emits that reasoning only once per provider inference. Reconstruct the + // same presentation boundary from the usage ledger Goose attaches to the + // inference's final assistant message. If no ledger boundary is reachable + // before the next real user turn, preserve every block rather than guess. + // Replace this reconstruction if Goose adds an explicit persisted + // inference ID or replay marker to its public message contract. + let has_usage_boundary = + assistant && provider_inference_has_usage_boundary(&messages[index..]); + if assistant + && has_tool_request + && state.surfaced_thinking_in_inference + && has_usage_boundary + { + thinking = None; + } else if assistant && thinking.is_some() { + state.surfaced_thinking_in_inference = true; + } + + items.extend(message_to_timeline_items_with_thinking( + message, + false, + thinking.as_deref(), + )); + + if inference_ends { + state.surfaced_thinking_in_inference = false; + } + } + + coalesce_timeline_items(items) +} + +fn is_real_user_message(message: &Message, role: &str) -> bool { + role == "user" + && message + .content + .iter() + .any(|content| !matches!(content, MessageContent::ToolResponse(_))) +} + +fn provider_inference_has_usage_boundary(messages: &[Message]) -> bool { + for message in messages { + let role = message_role(message); + if is_real_user_message(message, &role) { + return false; + } + if role == "assistant" && message.metadata.usage.is_some() { + return true; + } + } + false +} + +fn message_thinking_projection(message: &Message) -> Option { + // Match Goose Desktop's ACP adapter: concatenate adjacent thought chunks + // by message without rewriting their text. The frontend decides whether + // the fully merged thought is renderable, so a streamed punctuation or + // whitespace suffix is never lost. + let mut text = String::new(); + let mut found = false; + + for content in &message.content { + match content { + MessageContent::Thinking(thinking) => { + found = true; + text.push_str(&thinking.thinking); + } + MessageContent::RedactedThinking(_) => { + found = true; + text.push_str("Thinking redacted by provider."); + } + _ => {} + } + } + found.then_some(text) +} + +fn message_to_timeline_items(message: &Message, live: bool) -> Vec { + if !message.is_user_visible() { + return Vec::new(); + } + let thinking = message_thinking_projection(message); + message_to_timeline_items_with_thinking(message, live, thinking.as_deref()) +} + +fn message_to_timeline_items_with_thinking( + message: &Message, + live: bool, + thinking: Option<&str>, +) -> Vec { + let role = message_role(message); + let base_id = message + .id + .clone() + .unwrap_or_else(|| format!("message-{}-{}", role, message.created)); + let created_ms = if message.created > 0 { + (message.created as u128) * 1000 + } else { + unix_ms() + }; + let merge = if live { "append" } else { "replace" }.to_string(); + + let mut emitted_thinking = false; + message + .content + .iter() + .enumerate() + .filter_map(|(index, content)| match content { + MessageContent::Text(text) => Some(AgentTimelineItem { + id: format!("{base_id}-text"), + item_type: "message".to_string(), + role: Some(role.clone()), + title: None, + text: Some(text.text.clone()), + status: None, + input: None, + output: None, + created_ms, + merge: merge.clone(), + }), + MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) => { + if emitted_thinking { + return None; + } + emitted_thinking = true; + thinking.map(|thinking| AgentTimelineItem { + id: format!("{base_id}-thinking"), + item_type: "thinking".to_string(), + role: Some("thought".to_string()), + title: Some("Thinking".to_string()), + text: Some(thinking.to_string()), + status: None, + input: None, + output: None, + created_ms, + merge: merge.clone(), + }) + } + MessageContent::ToolRequest(request) => Some(tool_request_item(request, created_ms)), + MessageContent::ToolResponse(response) => { + Some(tool_response_item(response, created_ms)) + } + MessageContent::ToolConfirmationRequest(request) => Some(AgentTimelineItem { + id: format!("permission-{}", request.id), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some(format_tool_title(&request.tool_name)), + text: request.prompt.clone(), + status: Some("pending".to_string()), + input: Some(Value::Object(request.arguments.clone())), + output: None, + created_ms, + merge: "replace".to_string(), + }), + MessageContent::ActionRequired(action) => { + Some(action_required_item(action, created_ms)) + } + MessageContent::FrontendToolRequest(request) => { + let (title, text, input, status) = match &request.tool_call { + Ok(call) => ( + format_tool_title(call.name.as_ref()), + None, + Some(serde_json::to_value(&call.arguments).unwrap_or(Value::Null)), + "pending".to_string(), + ), + Err(error) => ( + "Tool call parse failed".to_string(), + Some(bounded_timeline_text( + &error.to_string(), + MAX_AGENT_ERROR_CHARS, + )), + None, + "failed".to_string(), + ), + }; + Some(AgentTimelineItem { + id: request.id.clone(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: Some(title), + text, + status: Some(status), + input, + output: None, + created_ms, + merge: "replace".to_string(), + }) + } + MessageContent::SystemNotification(notification) => Some(system_notification_item( + &base_id, + index, + notification, + created_ms, + )), + // Images are provider-history payloads, not timeline events. The + // read_image tool request/result already gives users the useful, + // bounded presentation without exposing base64 metadata. + MessageContent::Image(_) => None, + }) + .collect() +} + +fn system_notification_item( + base_id: &str, + index: usize, + notification: &SystemNotificationContent, + created_ms: u128, +) -> AgentTimelineItem { + let title = match notification.notification_type { + SystemNotificationType::ThinkingMessage => "Thinking", + SystemNotificationType::ProgressMessage => "Progress", + SystemNotificationType::InlineMessage => "Agent notice", + SystemNotificationType::CreditsExhausted => "Credits exhausted", + }; + AgentTimelineItem { + id: format!("{base_id}-system-{index}"), + item_type: "system".to_string(), + role: Some("system".to_string()), + title: Some(title.to_string()), + text: Some(bounded_timeline_text(¬ification.msg, 500)), + status: None, + input: None, + // Provider-specific structured data can contain raw request or model + // payloads. The stable title/message above is the user-facing contract. + output: None, + created_ms, + merge: "replace".to_string(), + } +} + +fn tool_request_item( + request: &goose::conversation::message::ToolRequest, + created_ms: u128, +) -> AgentTimelineItem { + match &request.tool_call { + Ok(call) => AgentTimelineItem { + id: request.id.clone(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: Some( + request + .persisted_title() + .unwrap_or_else(|| call.name.as_ref()) + .to_string(), + ), + text: request + .persisted_chain_summary() + .map(|summary| summary.summary), + status: Some("running".to_string()), + input: Some(serde_json::to_value(&call.arguments).unwrap_or(Value::Null)), + output: None, + created_ms, + merge: "replace".to_string(), + }, + Err(error) => error_item(format!("Tool call parse failed: {error}")), + } +} + +fn tool_response_item( + response: &goose::conversation::message::ToolResponse, + created_ms: u128, +) -> AgentTimelineItem { + match &response.tool_result { + Ok(result) => { + let text = result + .content + .iter() + .filter_map(|content| content.as_text().map(|text| text.text.to_string())) + .collect::>() + .join("\n"); + let content = result + .content + .iter() + .map(summarize_tool_content) + .collect::>(); + AgentTimelineItem { + id: response.id.clone(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: tool_name_from_id(&response.id).map(|name| format_tool_title(&name)), + text: None, + status: Some( + if result.is_error.unwrap_or(false) { + "failed" + } else { + "completed" + } + .to_string(), + ), + input: None, + output: Some(json!({ + "text": text, + "isError": result.is_error, + "structuredContent": result.structured_content, + "content": content, + })), + created_ms, + merge: "replace".to_string(), + } + } + Err(error) => AgentTimelineItem { + id: response.id.clone(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: tool_name_from_id(&response.id).map(|name| format_tool_title(&name)), + text: Some(bounded_timeline_text( + &error.to_string(), + MAX_AGENT_ERROR_CHARS, + )), + status: Some("failed".to_string()), + input: None, + output: None, + created_ms, + merge: "replace".to_string(), + }, + } +} + +fn summarize_tool_content(content: &rmcp::model::Content) -> Value { + if let Some(text) = content.as_text() { + return json!({ + "type": "text", + "text": text.text, + }); + } + + if let Some(image) = content.as_image() { + return image_metadata_value(&image.mime_type, image.data.len()); + } + + json!({ + "type": "other", + "dataOmitted": true, + }) +} + +fn image_metadata_value(mime_type: &str, base64_chars: usize) -> Value { + json!({ + "type": "image", + "mimeType": mime_type, + "base64Chars": base64_chars, + "dataOmitted": true, + }) +} + +fn coalesce_timeline_items(items: Vec) -> Vec { + items.into_iter().fold(Vec::new(), merge_timeline_item) +} + +fn merge_timeline_item( + mut current: Vec, + incoming: AgentTimelineItem, +) -> Vec { + let Some(index) = current.iter().position(|item| item.id == incoming.id) else { + current.push(incoming); + return current; + }; + + let previous = current[index].clone(); + let append_text = incoming.merge == "append" + && matches!(incoming.item_type.as_str(), "message" | "thinking") + && incoming.text.is_some(); + + current[index] = AgentTimelineItem { + id: incoming.id, + item_type: incoming.item_type, + role: incoming.role.or(previous.role), + title: incoming.title.or(previous.title), + text: if append_text { + Some(format!( + "{}{}", + previous.text.unwrap_or_default(), + incoming.text.unwrap_or_default() + )) + } else { + incoming.text.or(previous.text) + }, + status: incoming.status.or(previous.status), + input: incoming.input.or(previous.input), + output: incoming.output.or(previous.output), + created_ms: incoming.created_ms, + merge: incoming.merge, + }; + + current +} + +fn action_required_item( + action: &goose::conversation::message::ActionRequired, + created_ms: u128, +) -> AgentTimelineItem { + match &action.data { + ActionRequiredData::ToolConfirmation { + id, + tool_name, + arguments, + prompt, + } => AgentTimelineItem { + id: format!("permission-{id}"), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some(format_tool_title(tool_name)), + text: prompt.clone(), + status: Some("pending".to_string()), + input: Some(Value::Object(arguments.clone())), + output: None, + created_ms, + merge: "replace".to_string(), + }, + ActionRequiredData::Elicitation { + id, + message, + requested_schema, + } => AgentTimelineItem { + id: format!("elicitation-{id}"), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some("Input requested".to_string()), + text: Some(message.clone()), + status: Some("pending".to_string()), + input: Some(requested_schema.clone()), + output: None, + created_ms, + merge: "replace".to_string(), + }, + ActionRequiredData::ElicitationResponse { id, .. } => AgentTimelineItem { + id: format!("elicitation-response-{id}"), + item_type: "system".to_string(), + role: Some("system".to_string()), + title: Some("Input response".to_string()), + text: None, + status: Some("completed".to_string()), + input: None, + output: None, + created_ms, + merge: "replace".to_string(), + }, + } +} + +fn error_item(message: String) -> AgentTimelineItem { + AgentTimelineItem { + id: format!("error-{}", unix_ms()), + item_type: "error".to_string(), + role: Some("system".to_string()), + title: Some("Agent error".to_string()), + text: Some(bounded_timeline_text(&message, MAX_AGENT_ERROR_CHARS)), + status: Some("failed".to_string()), + input: None, + output: None, + created_ms: unix_ms(), + merge: "replace".to_string(), + } +} + +fn message_role(message: &Message) -> String { + serde_json::to_value(&message.role) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| format!("{:?}", message.role).to_lowercase()) +} + +fn format_tool_title(name: &str) -> String { + let normalized = name.replace("__", ": ").replace('_', " "); + normalized + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() +} + +fn tool_name_from_id(id: &str) -> Option { + // Goose's `functions.:` IDs encode a tool name. Provider + // IDs such as `chatcmpl-tool-*` do not; returning a title for those would + // overwrite the request's already-correct title during timeline merging. + let name = id + .strip_prefix("functions.")? + .split(':') + .next() + .unwrap_or("") + .trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +fn permission_from_decision(decision: &str) -> Result { + match decision { + "allow_once" | "allow" => Ok(Permission::AllowOnce), + "always_allow" => Ok(Permission::AlwaysAllow), + "deny_once" | "deny" => Ok(Permission::DenyOnce), + "always_deny" => Ok(Permission::AlwaysDeny), + "cancel" => Ok(Permission::Cancel), + other => Err(format!("Unknown permission decision: {other}")), + } +} + +fn session_summary(session: &Session) -> AgentSessionSummary { + AgentSessionSummary { + id: session.id.clone(), + title: session.name.clone(), + project_root: path_string(&session.working_dir), + created_ms: session.created_at.timestamp_millis(), + updated_ms: session.updated_at.timestamp_millis(), + message_count: session.message_count, + model: session + .model_config + .as_ref() + .map(|model| model.model_name.clone()), + mode: session.goose_mode.to_string(), + } +} + +fn emit_timeline_item( + app_handle: &AppHandle, + session_id: &str, + run_id: &str, + item: AgentTimelineItem, +) { + emit_agent_event( + app_handle, + AgentEventEnvelope { + event_type: "timelineItem".to_string(), + session_id: Some(session_id.to_string()), + run_id: Some(run_id.to_string()), + item: Some(item), + status: None, + session: None, + message: None, + }, + ); +} + +async fn record_and_emit_timeline_item( + app_handle: &AppHandle, + live_timelines: &LiveTimelines, + session_id: &str, + run_id: &str, + item: AgentTimelineItem, +) { + record_timeline_item(live_timelines, session_id, item.clone()).await; + emit_timeline_item(app_handle, session_id, run_id, item); +} + +async fn record_timeline_item( + live_timelines: &LiveTimelines, + session_id: &str, + item: AgentTimelineItem, +) { + let mut timelines = live_timelines.lock().await; + let current = match timelines.remove(session_id) { + Some(LiveTimeline::Streaming(items)) => items, + // A real user message starts a new live suffix. The preceding terminal + // row is either already persisted or was a one-turn-only error/notice; + // carrying it forward could duplicate it on a mid-run session reload. + Some(LiveTimeline::Completed(_) | LiveTimeline::Failed(_)) + if is_user_message_item(&item) => + { + Vec::new() + } + Some(LiveTimeline::Completed(candidate)) => candidate.items, + Some(LiveTimeline::Failed(items)) => items, + None => Vec::new(), + }; + timelines.insert( + session_id.to_string(), + LiveTimeline::Streaming(merge_timeline_item(current, item)), + ); +} + +/// Goose replaces persisted history during compaction, so any live rows from +/// before that replacement are stale. Keep only the newest visible real-user +/// row as an ID boundary for later events in the still-running turn. A session +/// reload can then use Goose's live presentation suffix wholesale instead of +/// merging it with differently-IDed provider-history reasoning. +async fn reseed_live_timeline_after_history_replaced( + live_timelines: &LiveTimelines, + session_id: &str, + conversation: &Conversation, +) { + let replacement_boundary = conversation + .messages() + .iter() + .rev() + .find(|message| { + let role = message_role(message); + message.is_user_visible() && is_real_user_message(message, &role) + }) + .and_then(|message| { + coalesce_timeline_items(message_to_timeline_items(message, false)) + .into_iter() + .find(is_user_message_item) + }); + + let mut timelines = live_timelines.lock().await; + match replacement_boundary { + Some(replacement_boundary) => { + // Prefer the existing live representation, but only for the user + // ID confirmed by Goose's replacement history. That preserves the + // authoritative presentation item without retaining a boundary + // that compaction or an explicit history command removed. + let boundary = timelines + .get(session_id) + .and_then(|items| { + items.items().iter().rev().find(|item| { + is_user_message_item(item) && item.id == replacement_boundary.id + }) + }) + .cloned() + .unwrap_or(replacement_boundary); + timelines.insert( + session_id.to_string(), + LiveTimeline::Streaming(vec![boundary]), + ); + } + None => { + timelines.remove(session_id); + } + } +} + +async fn overlay_live_timeline( + live_timelines: &LiveTimelines, + session_id: &str, + conversation: &Conversation, + persisted: Vec, +) -> Vec { + let live_items = { + let mut timelines = live_timelines.lock().await; + match timelines.get(session_id).cloned() { + Some(LiveTimeline::Streaming(items)) => items, + Some(LiveTimeline::Completed(candidate)) => { + // agent_load_session already paid to load Goose history. Use + // that snapshot here instead of deserializing it a second time + // at the end of every prompt. + if terminal_message_is_persisted(conversation, &candidate) { + timelines.remove(session_id); + Vec::new() + } else { + candidate.items + } + } + Some(LiveTimeline::Failed(items)) => items, + None => Vec::new(), + } + }; + if live_items.is_empty() { + return persisted; + } + + overlay_live_timeline_items(persisted, live_items) +} + +fn overlay_live_timeline_items( + persisted: Vec, + live_items: Vec, +) -> Vec { + // AgentEvent is Goose's authoritative presentation stream. Once its first + // user boundary also exists in persisted history, keep only the persisted + // prefix before that turn and use the live suffix wholesale. This avoids + // matching or rewriting reasoning text when Goose's provider-history copy + // has a different message ID from the live thought. + let persisted_boundary = live_items + .iter() + .filter(|item| is_user_message_item(item)) + .find_map(|live_user| persisted.iter().position(|item| item.id == live_user.id)); + let mut timeline = match persisted_boundary { + Some(index) => persisted[..index].to_vec(), + None => persisted, + }; + timeline.extend(live_items.into_iter().map(live_overlay_item)); + coalesce_timeline_items(timeline) +} + +fn is_user_message_item(item: &AgentTimelineItem) -> bool { + item.item_type == "message" && item.role.as_deref() == Some("user") +} + +fn live_overlay_item(mut item: AgentTimelineItem) -> AgentTimelineItem { + item.merge = "replace".to_string(); + item +} + +async fn update_live_permission_status( + live_timelines: &LiveTimelines, + session_id: &str, + request_id: &str, + decision: &str, +) -> Option { + let permission_id = format!("permission-{request_id}"); + let mut timelines = live_timelines.lock().await; + let items = timelines.get_mut(session_id)?.items_mut(); + let item = items.iter_mut().find(|item| item.id == permission_id)?; + item.status = Some(decision.to_string()); + item.merge = "replace".to_string(); + Some(item.clone()) +} + +fn emit_agent_event(app_handle: &AppHandle, event: AgentEventEnvelope) { + if let Err(error) = app_handle.emit(AGENT_EVENT_NAME, event) { + log::warn!("Failed to emit Agent Mode event: {error}"); + } +} + +fn configure_embedded_goose( + goose_path_root: &Path, + model: &str, + mode: &str, + maple_proxy_base_url: &str, +) -> Result<(), String> { + fs::create_dir_all(goose_path_root.join("config")) + .map_err(|e| format!("Failed to create Goose config dir: {e}"))?; + fs::create_dir_all(goose_path_root.join("data")) + .map_err(|e| format!("Failed to create Goose data dir: {e}"))?; + fs::create_dir_all(goose_path_root.join("state")) + .map_err(|e| format!("Failed to create Goose state dir: {e}"))?; + + std::env::set_var("GOOSE_PATH_ROOT", goose_path_root); + // The embedded Goose provider talks only to Maple's loopback proxy. The + // proxy owns upstream authentication, so Goose must not receive or persist + // an API key of its own. + std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("GOOSE_DISABLE_KEYRING"); + std::env::remove_var("GOOSE_MAX_TOKENS"); + + remove_maple_owned_secret_file(&goose_path_root.join("config").join("secrets.yaml"))?; + let config = goose::config::Config::global(); + config.invalidate_secrets_cache(); + delete_goose_config_key(config, "GOOSE_DISABLE_KEYRING")?; + delete_goose_config_key(config, "GOOSE_MAX_TOKENS")?; + goose::config::set_active_provider(config, "openai", model) + .map_err(|e| format!("Failed to configure Goose provider: {e}"))?; + config + .set_param("GOOSE_FAST_MODEL", model) + .map_err(|e| format!("Failed to configure Goose fast model: {e}"))?; + config + .set_param("GOOSE_MODE", mode) + .map_err(|e| format!("Failed to configure Goose mode: {e}"))?; + config + .set_param("OPENAI_BASE_URL", format!("{maple_proxy_base_url}/v1")) + .map_err(|e| format!("Failed to configure Goose OpenAI base URL: {e}"))?; + + set_owner_only_permissions(&goose_path_root.join("config").join("config.yaml")); + Ok(()) +} + +fn delete_goose_config_key(config: &goose::config::Config, key: &str) -> Result<(), String> { + match config.delete(key) { + Ok(()) | Err(ConfigError::NotFound(_)) => Ok(()), + Err(e) => Err(format!("Failed to clear Goose config key {key}: {e}")), + } +} + +fn remove_maple_owned_secret_file(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "Failed to remove Maple-owned Goose secrets file {}: {error}", + path.display() + )), + } +} + +fn parse_goose_mode(mode: &str) -> GooseMode { + GooseMode::from_str(mode).unwrap_or(GooseMode::SmartApprove) +} + +fn stopped_status() -> AgentRuntimeStatus { + AgentRuntimeStatus { + running: false, + project_root: None, + model: None, + mode: None, + active_runs: HashMap::new(), + } +} + +fn resolve_project_root(requested: Option<&str>, config: &AgentConfig) -> Result { + if let Some(path) = requested.filter(|value| !value.trim().is_empty()) { + return normalize_project_root(Path::new(path)); + } + + if let Some(path) = config + .default_project_root + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + if let Ok(root) = normalize_project_root(Path::new(path)) { + return Ok(root); + } + } + + std::env::current_dir() + .map_err(|e| format!("Failed to read current directory: {e}")) + .and_then(|path| normalize_project_root(&path)) +} + +fn normalize_project_root(path: &Path) -> Result { + let canonical = path + .canonicalize() + .map_err(|e| format!("{}: {e}", path.display()))?; + if !canonical.is_dir() { + return Err(format!("{} is not a folder", canonical.display())); + } + Ok(canonical) +} + +fn agent_root_dir(app_handle: &AppHandle) -> Result { + let base = app_handle + .path() + .app_config_dir() + .map_err(|error| anyhow::anyhow!("Failed to resolve app config dir: {error}"))?; + let path = base.join("agent"); + fs::create_dir_all(&path)?; + set_owner_only_dir_permissions(&path); + Ok(path) +} + +fn account_config_dir_path( + app_handle: &AppHandle, + user_id: &str, +) -> Result { + let scope = account_scope(user_id).map_err(anyhow::Error::msg)?; + Ok(agent_root_dir(app_handle)?.join("accounts").join(scope)) +} + +fn agent_config_dir(app_handle: &AppHandle, user_id: &str) -> Result { + let path = account_config_dir_path(app_handle, user_id)?; + fs::create_dir_all(&path)?; + set_owner_only_dir_permissions(&path); + Ok(path) +} + +fn account_session_manager( + app_handle: &AppHandle, + user_id: &str, +) -> Result, String> { + let account_dir = agent_config_dir(app_handle, user_id).map_err(|error| error.to_string())?; + session_manager_for_account_dir(&account_dir) +} + +fn session_manager_for_account_dir(account_dir: &Path) -> Result, String> { + let data_dir = account_dir.join("goose/data"); + fs::create_dir_all(&data_dir) + .map_err(|error| format!("Failed to create Goose data dir: {error}"))?; + Ok(Arc::new(SessionManager::new(data_dir))) +} + +fn clear_agent_history(account_dir: &Path) -> Result<(), anyhow::Error> { + remove_agent_history_path(&account_dir.join("goose/data")) +} + +fn remove_agent_history_path(path: &Path) -> Result<(), anyhow::Error> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + let result = if metadata.file_type().is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + result.map_err(Into::into) +} +fn load_agent_config_inner( + app_handle: &AppHandle, + user_id: &str, +) -> Result { + let path = agent_config_dir(app_handle, user_id)?.join("config.json"); + if !path.exists() { + return Ok(AgentConfig::default()); + } + let contents = fs::read_to_string(path)?; + let mut config: AgentConfig = serde_json::from_str(&contents)?; + if migrate_agent_config(&mut config) { + save_agent_config_inner(app_handle, user_id, &config)?; + } + Ok(config) +} + +fn migrate_agent_config(config: &mut AgentConfig) -> bool { + if config.default_model != LEGACY_AGENT_DEFAULT_MODEL { + return false; + } + config.default_model = default_agent_model(); + true +} + +fn save_agent_config_inner( + app_handle: &AppHandle, + user_id: &str, + config: &AgentConfig, +) -> Result<(), anyhow::Error> { + let path = agent_config_dir(app_handle, user_id)?.join("config.json"); + write_json_file(&path, config) +} + +fn load_recent_project_roots_inner( + app_handle: &AppHandle, + user_id: &str, +) -> Result, anyhow::Error> { + let path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); + if !path.exists() { + return Ok(Vec::new()); + } + let contents = fs::read_to_string(path)?; + Ok(serde_json::from_str(&contents)?) +} + +fn save_recent_project_root_inner( + app_handle: &AppHandle, + user_id: &str, + project_root: &Path, +) -> Result, anyhow::Error> { + let mut roots = load_recent_project_roots_inner(app_handle, user_id).unwrap_or_default(); + let path = path_string(project_root); + roots.retain(|root| root.path != path); + roots.insert( + 0, + RecentProjectRoot { + name: project_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(&path) + .to_string(), + path, + last_used_ms: unix_ms(), + }, + ); + roots.truncate(20); + + let file_path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); + write_json_file(&file_path, &roots)?; + Ok(roots) +} + +fn has_active_session_run(active_runs: &HashMap, session_id: &str) -> bool { + active_runs.values().any(|run| run.session_id == session_id) +} + +fn write_json_file(path: &Path, value: &T) -> Result<(), anyhow::Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, serde_json::to_string_pretty(value)?)?; + set_owner_only_permissions(path); + Ok(()) +} + +#[cfg(unix)] +fn set_owner_only_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); +} + +#[cfg(not(unix))] +fn set_owner_only_permissions(_path: &Path) {} + +#[cfg(unix)] +fn set_owner_only_dir_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700)); +} + +#[cfg(not(unix))] +fn set_owner_only_dir_permissions(_path: &Path) {} + +fn unix_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_agent_config_defaults_to_glm() { + assert_eq!(AgentConfig::default().default_model, DEFAULT_AGENT_MODEL); + + let config: AgentConfig = serde_json::from_value(json!({ + "defaultProjectRoot": null, + "runtimeKind": "goose-direct" + })) + .expect("legacy config without a model should deserialize"); + assert_eq!(config.default_model, DEFAULT_AGENT_MODEL); + } + + #[test] + fn legacy_powerful_agent_default_migrates_to_glm() { + let mut config = AgentConfig { + default_project_root: Some("/tmp/project".to_string()), + default_model: LEGACY_AGENT_DEFAULT_MODEL.to_string(), + }; + + assert!(migrate_agent_config(&mut config)); + assert_eq!(config.default_model, DEFAULT_AGENT_MODEL); + assert!(!migrate_agent_config(&mut config)); + } + + #[test] + fn explicit_agent_model_choices_are_not_migrated() { + for model in ["kimi-k2-6", "auto:quick", "glm-5-2", "gemma-3-27b"] { + let mut config = AgentConfig { + default_project_root: None, + default_model: model.to_string(), + }; + + assert!(!migrate_agent_config(&mut config)); + assert_eq!(config.default_model, model); + } + } + + #[test] + fn image_history_payloads_do_not_create_timeline_rows() { + let message = Message::user() + .with_id("image-message") + .with_text("Inspect this image") + .with_image("aW1hZ2U=", "image/png"); + + let items = message_to_timeline_items(&message, false); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "image-message-text"); + assert_eq!(items[0].text.as_deref(), Some("Inspect this image")); + } + + #[test] + fn agent_errors_are_bounded_for_the_timeline() { + let item = error_item("x".repeat(MAX_AGENT_ERROR_CHARS + 100)); + let text = item.text.expect("error should contain a summary"); + + assert_eq!(text.chars().count(), MAX_AGENT_ERROR_CHARS + 1); + assert!(text.ends_with('…')); + } + + #[test] + fn terminal_projection_handles_stream_ids_and_idless_collisions() { + let mut first_chunk = Message::assistant() + .with_id("stream-message") + .with_text("Hello"); + first_chunk.created = 100; + let mut second_chunk = Message::assistant() + .with_id("stream-message") + .with_text(" world"); + second_chunk.created = 101; + let first_items = message_to_timeline_items(&first_chunk, true); + let second_items = message_to_timeline_items(&second_chunk, true); + let candidate = update_live_message_candidate( + Some(live_message_candidate(&first_chunk, &first_items)), + &second_chunk, + &second_items, + ); + + let mut persisted = Message::assistant() + .with_id("stream-message") + .with_text("Hello world"); + persisted.created = 100; + let conversation = Conversation::new_unvalidated(vec![persisted]); + assert!(terminal_message_is_persisted(&conversation, &candidate)); + + let mut persisted_reply = Message::assistant().with_text("Persisted reply"); + persisted_reply.created = 200; + let stored_reply = persisted_reply.clone().with_id("database-id"); + let reply_items = message_to_timeline_items(&persisted_reply, true); + let reply_candidate = live_message_candidate(&persisted_reply, &reply_items); + assert!(terminal_message_is_persisted( + &Conversation::new_unvalidated(vec![stored_reply.clone()]), + &reply_candidate + )); + + let mut live_only_notice = Message::assistant().with_text("Transient provider error"); + live_only_notice.created = persisted_reply.created; + let notice_items = message_to_timeline_items(&live_only_notice, true); + let notice_candidate = + update_live_message_candidate(Some(reply_candidate), &live_only_notice, ¬ice_items); + assert_eq!(notice_candidate.items.len(), 1); + assert_eq!( + notice_candidate.items[0].text.as_deref(), + Some("Transient provider error") + ); + assert!(!terminal_message_is_persisted( + &Conversation::new_unvalidated(vec![stored_reply]), + ¬ice_candidate + )); + + let mut same_id_notice = Message::assistant() + .with_id("stream-message") + .with_system_notification(SystemNotificationType::InlineMessage, "Live-only notice"); + same_id_notice.created = 100; + let same_id_items = message_to_timeline_items(&same_id_notice, true); + let same_id_candidate = + update_live_message_candidate(Some(candidate), &same_id_notice, &same_id_items); + assert_eq!(same_id_candidate.items.len(), 1); + assert!(!terminal_message_is_persisted( + &conversation, + &same_id_candidate + )); + } + + #[tokio::test] + async fn completed_timeline_reuses_session_load_and_retains_only_live_only_message() { + let session_id = "session"; + let mut live_reply = Message::assistant().with_text("Persisted reply"); + live_reply.created = 300; + let stored_reply = live_reply.clone().with_id("database-id"); + let persisted_conversation = Conversation::new_unvalidated(vec![stored_reply]); + let persisted_timeline = conversation_to_timeline_items(&persisted_conversation); + let reply_items = message_to_timeline_items(&live_reply, true); + let reply_candidate = live_message_candidate(&live_reply, &reply_items); + let live_timelines = Arc::new(Mutex::new(HashMap::from([( + session_id.to_string(), + LiveTimeline::Completed(reply_candidate), + )]))); + + let loaded = overlay_live_timeline( + &live_timelines, + session_id, + &persisted_conversation, + persisted_timeline.clone(), + ) + .await; + assert_eq!(loaded.len(), persisted_timeline.len()); + assert!(!live_timelines.lock().await.contains_key(session_id)); + + let mut notice = Message::assistant().with_text("Transient provider error"); + notice.created = live_reply.created; + let notice_items = message_to_timeline_items(¬ice, true); + let notice_candidate = live_message_candidate(¬ice, ¬ice_items); + let mut timelines = HashMap::new(); + + apply_successful_prompt_outcome( + &mut timelines, + session_id, + &AgentPromptOutcome { + terminal_message: Some(notice_candidate), + }, + ); + let live_timelines = Arc::new(Mutex::new(timelines)); + let loaded = overlay_live_timeline( + &live_timelines, + session_id, + &persisted_conversation, + persisted_timeline, + ) + .await; + assert_eq!( + loaded.last().and_then(|item| item.text.as_deref()), + Some("Transient provider error") + ); + assert!(matches!( + live_timelines.lock().await.get(session_id), + Some(LiveTimeline::Completed(_)) + )); + + let mut timelines = live_timelines.lock().await; + apply_successful_prompt_outcome(&mut timelines, session_id, &AgentPromptOutcome::default()); + assert!(!timelines.contains_key(session_id)); + } + + #[tokio::test] + async fn failed_prompt_outcome_keeps_only_the_latest_error() { + let session_id = "failed-session"; + let prior_turn = message_to_timeline_items( + &Message::user() + .with_id("prior-user") + .with_text("Prior turn"), + false, + ); + let mut timelines = + HashMap::from([(session_id.to_string(), LiveTimeline::Streaming(prior_turn))]); + + apply_failed_prompt_outcome( + &mut timelines, + session_id, + error_item("First failure".to_string()), + ); + apply_failed_prompt_outcome( + &mut timelines, + session_id, + error_item("Second failure".to_string()), + ); + + let LiveTimeline::Failed(items) = timelines.get(session_id).unwrap() else { + panic!("failed run should leave a bounded failed timeline"); + }; + assert_eq!(items.len(), 1); + assert_eq!(items[0].text.as_deref(), Some("Second failure")); + + let live_timelines = Arc::new(Mutex::new(timelines)); + let next_user = message_to_timeline_items( + &Message::user().with_id("next-user").with_text("Retry"), + false, + ) + .into_iter() + .next() + .unwrap(); + record_timeline_item(&live_timelines, session_id, next_user).await; + let timelines = live_timelines.lock().await; + let LiveTimeline::Streaming(items) = timelines.get(session_id).unwrap() else { + panic!("a retry should start a fresh streaming timeline"); + }; + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "next-user-text"); + } + + fn write_test_file(path: &Path) { + fs::create_dir_all(path.parent().expect("test file should have a parent")) + .expect("test file parent should be created"); + fs::write(path, b"sentinel").expect("test file should be written"); + } + + fn assistant_tool_message( + message_id: &str, + tool_id: &str, + thinking: &str, + signature: &str, + ) -> Message { + Message::assistant() + .with_id(message_id) + .with_thinking(thinking, signature) + .with_tool_request( + tool_id, + Ok(rmcp::model::CallToolRequestParams::new("shell")), + ) + } + + fn with_usage(mut message: Message) -> Message { + message.metadata.usage = Some(Box::default()); + message + } + + fn assistant_redacted_tool_message( + message_id: &str, + tool_id: &str, + redacted_data: &str, + ) -> Message { + Message::assistant() + .with_id(message_id) + .with_redacted_thinking(redacted_data) + .with_tool_request( + tool_id, + Ok(rmcp::model::CallToolRequestParams::new("shell")), + ) + } + + fn tool_response_message(message_id: &str, tool_id: &str) -> Message { + Message::user().with_id(message_id).with_tool_response( + tool_id, + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text("ok"), + ])), + ) + } + + fn timeline_thinking_texts(items: &[AgentTimelineItem]) -> Vec<&str> { + items + .iter() + .filter(|item| item.item_type == "thinking") + .filter_map(|item| item.text.as_deref()) + .collect() + } + + fn merge_test_timeline_items( + mut current: Vec, + incoming: Vec, + ) -> Vec { + for item in incoming { + current = merge_timeline_item(current, item); + } + current + } + + #[test] + fn joins_thinking_fragments_within_each_goose_message() { + let message = Message::assistant() + .with_id("assistant-1") + .with_thinking("I can", "") + .with_thinking(" help.", "") + .with_text("Done"); + let conversation = Conversation::new_unvalidated(vec![message.clone()]); + + let live = message_to_timeline_items(&message, true); + let loaded = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&live), vec!["I can help."]); + assert_eq!(timeline_thinking_texts(&loaded), vec!["I can help."]); + assert_eq!( + loaded + .iter() + .find(|item| item.item_type == "thinking") + .map(|item| item.id.as_str()), + Some("assistant-1-thinking") + ); + } + + #[test] + fn hides_tool_reasoning_after_prior_visible_thinking() { + let surfaced = "Inspect the project before running both commands."; + let tool_attached = "Reasoning accumulated before the tool request."; + let conversation = Conversation::new_unvalidated(vec![ + Message::assistant() + .with_id("surfaced") + .with_thinking(surfaced, "") + .with_text("Starting now."), + assistant_tool_message("request-1", "tool-1", tool_attached, ""), + tool_response_message("response-1", "tool-1"), + with_usage(assistant_tool_message( + "request-2", + "tool-2", + tool_attached, + "", + )), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![surfaced]); + assert_eq!( + items.iter().filter(|item| item.item_type == "tool").count(), + 2 + ); + } + + #[test] + fn suppresses_replayed_thinking_on_split_tool_requests() { + let reasoning = "Run both requested commands."; + let conversation = Conversation::new_unvalidated(vec![ + assistant_tool_message("request-1", "tool-1", reasoning, ""), + tool_response_message("response-1", "tool-1"), + with_usage(assistant_tool_message( + "request-2", + "tool-2", + "A later accumulated copy from the same inference.", + "", + )), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![reasoning]); + assert_eq!( + items.iter().filter(|item| item.item_type == "tool").count(), + 2 + ); + } + + #[test] + fn usage_boundary_preserves_identical_thinking_in_the_next_inference() { + let reasoning = "Run the requested command."; + let conversation = Conversation::new_unvalidated(vec![ + with_usage(assistant_tool_message("request-1", "tool-1", reasoning, "")), + tool_response_message("response-1", "tool-1"), + with_usage(assistant_tool_message("request-2", "tool-2", reasoning, "")), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![reasoning, reasoning]); + } + + #[test] + fn histories_without_usage_preserve_every_tool_thought() { + let conversation = Conversation::new_unvalidated(vec![ + assistant_tool_message("request-1", "tool-1", "First thought.", ""), + tool_response_message("response-1", "tool-1"), + assistant_tool_message("request-2", "tool-2", "Second thought.", ""), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!( + timeline_thinking_texts(&items), + vec!["First thought.", "Second thought."] + ); + } + + #[test] + fn preserves_legacy_thinking_text_for_the_rendering_boundary() { + let reasoning = "Inspect the repository and summarize it."; + let conversation = Conversation::new_unvalidated(vec![ + Message::assistant() + .with_id("standalone-reasoning") + .with_thinking(reasoning, ""), + Message::assistant() + .with_id("standalone-period") + .with_thinking(".", ""), + assistant_tool_message("request-1", "tool-1", ".", ""), + tool_response_message("response-1", "tool-1"), + with_usage(assistant_tool_message("request-2", "tool-2", ".", "")), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![reasoning, "."]); + assert_eq!( + items.iter().filter(|item| item.item_type == "tool").count(), + 2 + ); + } + + #[test] + fn live_thinking_chunks_match_persisted_message_projection() { + let user = Message::user() + .with_id("current-user") + .with_text("Inspect the project."); + let persisted_conversation = Conversation::new_unvalidated(vec![ + user.clone(), + Message::assistant() + .with_id("assistant") + .with_thinking(". ", "") + .with_thinking("First", "") + .with_thinking(" ", "") + .with_thinking("second", "") + .with_thinking(".", ""), + ]); + let persisted = conversation_to_timeline_items(&persisted_conversation); + let live_messages = vec![ + user, + Message::assistant() + .with_id("live-assistant") + .with_thinking(". ", ""), + Message::assistant() + .with_id("live-assistant") + .with_thinking("First", ""), + Message::assistant() + .with_id("live-assistant") + .with_thinking(" ", ""), + Message::assistant() + .with_id("live-assistant") + .with_thinking("second", ""), + Message::assistant() + .with_id("live-assistant") + .with_thinking(".", ""), + ]; + let live = live_messages + .into_iter() + .fold(Vec::new(), |items, message| { + merge_test_timeline_items(items, message_to_timeline_items(&message, true)) + }); + + assert_eq!(timeline_thinking_texts(&persisted), vec![". First second."]); + assert_eq!(timeline_thinking_texts(&live), vec![". First second."]); + } + + #[test] + fn suppresses_signed_thinking_replayed_within_one_inference() { + let conversation = Conversation::new_unvalidated(vec![ + assistant_tool_message("request-1", "tool-1", "Signed reasoning", "signature-a"), + tool_response_message("response-1", "tool-1"), + with_usage(assistant_tool_message( + "request-2", + "tool-2", + "Signed reasoning", + "signature-b", + )), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec!["Signed reasoning"]); + } + + #[test] + fn suppresses_redacted_thinking_replayed_within_one_inference() { + let conversation = Conversation::new_unvalidated(vec![ + assistant_redacted_tool_message("request-1", "tool-1", "opaque-payload-a"), + tool_response_message("response-1", "tool-1"), + with_usage(assistant_redacted_tool_message( + "request-2", + "tool-2", + "opaque-payload-b", + )), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!( + timeline_thinking_texts(&items), + vec!["Thinking redacted by provider."] + ); + } + + #[test] + fn preserves_reasoning_text_for_the_rendering_boundary() { + let conversation = Conversation::new_unvalidated(vec![ + Message::assistant() + .with_id("emoji") + .with_thinking("🤔", ""), + Message::assistant() + .with_id("operator") + .with_thinking("=>", ""), + Message::assistant() + .with_id("ellipsis") + .with_thinking("…...", ""), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec!["🤔", "=>", "…..."]); + } + + #[test] + fn unsigned_thinking_dedupe_resets_on_the_next_user_turn() { + let reasoning = "Run the requested command."; + let conversation = Conversation::new_unvalidated(vec![ + with_usage(assistant_tool_message("request-1", "tool-1", reasoning, "")), + tool_response_message("response-1", "tool-1"), + Message::user() + .with_id("next-turn") + .with_text("Run it again."), + with_usage(assistant_tool_message("request-2", "tool-2", reasoning, "")), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![reasoning, reasoning]); + } + + #[test] + fn hidden_goose_messages_neither_render_nor_consume_visible_replay() { + let reasoning = "Inspect the project."; + let hidden = Message::assistant() + .with_id("hidden-assistant") + .with_thinking(reasoning, "") + .with_text("internal grind details") + .with_visibility(false, true); + let conversation = Conversation::new_unvalidated(vec![ + Message::user().with_id("user").with_text("Inspect it."), + hidden.clone(), + with_usage(assistant_tool_message( + "visible-request", + "tool-1", + reasoning, + "", + )), + tool_response_message("response", "tool-1"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![reasoning]); + assert!(!items.iter().any(|item| { + item.id.starts_with("hidden-assistant") + || item.text.as_deref() == Some("internal grind details") + })); + assert!(message_to_timeline_items(&hidden, true).is_empty()); + } + + #[test] + fn hidden_usage_boundary_resets_visible_inference_state() { + let first = "First visible thought."; + let second = "Second visible thought."; + let hidden_boundary = with_usage( + Message::assistant() + .with_id("hidden-boundary") + .with_text("internal") + .with_visibility(false, true), + ); + let conversation = Conversation::new_unvalidated(vec![ + Message::assistant() + .with_id("first") + .with_thinking(first, ""), + hidden_boundary, + with_usage(assistant_tool_message("request", "tool", second, "")), + tool_response_message("response", "tool"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![first, second]); + } + + #[test] + fn hidden_user_message_still_resets_provider_turn_replay() { + let reasoning = "Run the requested command."; + let conversation = Conversation::new_unvalidated(vec![ + with_usage(assistant_tool_message("request-1", "tool-1", reasoning, "")), + tool_response_message("response-1", "tool-1"), + Message::user() + .with_id("hidden-user") + .with_text("internal retry turn") + .with_visibility(false, true), + with_usage(assistant_tool_message("request-2", "tool-2", reasoning, "")), + tool_response_message("response-2", "tool-2"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert_eq!(timeline_thinking_texts(&items), vec![reasoning, reasoning]); + assert!(!items.iter().any(|item| item.id.starts_with("hidden-user"))); + } + + #[test] + fn thinking_projection_is_session_local_and_does_not_mutate_history() { + let build_conversation = || { + Conversation::new_unvalidated(vec![ + assistant_tool_message("request-1", "tool-1", "Shared replay", ""), + tool_response_message("response-1", "tool-1"), + with_usage(assistant_tool_message( + "request-2", + "tool-2", + "Shared replay", + "", + )), + tool_response_message("response-2", "tool-2"), + ]) + }; + let first = build_conversation(); + let second = build_conversation(); + let first_before = first.clone(); + let second_before = second.clone(); + + let first_items = conversation_to_timeline_items(&first); + let second_items = conversation_to_timeline_items(&second); + + assert_eq!(timeline_thinking_texts(&first_items), vec!["Shared replay"]); + assert_eq!( + timeline_thinking_texts(&second_items), + vec!["Shared replay"] + ); + assert_eq!(first, first_before); + assert_eq!(second, second_before); + } + + #[test] + fn live_overlay_splices_at_the_first_shared_user_boundary() { + let prior_user = Message::user() + .with_id("prior-user") + .with_text("Earlier turn"); + let prior_assistant = Message::assistant() + .with_id("prior-assistant") + .with_text("Earlier answer"); + let current_user = Message::user() + .with_id("current-user") + .with_text("Current turn"); + let persisted_thought = Message::assistant() + .with_id("persisted-copy") + .with_thinking("Persisted provider-history copy", ""); + let live_thought = Message::assistant() + .with_id("live-thought") + .with_thinking("Authoritative live thought", ""); + + let persisted = [ + message_to_timeline_items(&prior_user, false), + message_to_timeline_items(&prior_assistant, false), + message_to_timeline_items(¤t_user, false), + message_to_timeline_items(&persisted_thought, false), + ] + .concat(); + let live = [ + message_to_timeline_items(¤t_user, true), + message_to_timeline_items(&live_thought, true), + ] + .concat(); + + let overlaid = overlay_live_timeline_items(persisted, live); + + assert!(overlaid.iter().any(|item| item.id == "prior-user-text")); + assert!(overlaid + .iter() + .any(|item| item.id == "prior-assistant-text")); + assert!(overlaid.iter().any(|item| item.id == "current-user-text")); + assert!(overlaid + .iter() + .any(|item| item.id == "live-thought-thinking")); + assert!(!overlaid + .iter() + .any(|item| item.id == "persisted-copy-thinking")); + } + + #[tokio::test] + async fn history_replaced_preserves_the_matching_live_user_boundary() { + let session_id = "history-replaced-boundary"; + let prior_user = Message::user() + .with_id("prior-user") + .with_text("Earlier turn"); + let current_user = Message::user() + .with_id("current-user") + .with_text("Current turn"); + let hidden_user = Message::user() + .with_id("hidden-user") + .with_text("Internal retry turn") + .with_visibility(false, true); + let conversation = Conversation::new_unvalidated(vec![ + prior_user, + current_user, + tool_response_message("tool-response", "tool-1"), + hidden_user, + ]); + let stale = message_to_timeline_items( + &Message::assistant() + .with_id("stale-live") + .with_thinking("Stale thought", ""), + true, + ); + let mut live = stale; + live.extend(message_to_timeline_items( + &Message::user() + .with_id("current-user") + .with_text("Live current turn"), + false, + )); + let live_timelines = Arc::new(Mutex::new(HashMap::from([( + session_id.to_string(), + LiveTimeline::Streaming(live), + )]))); + + reseed_live_timeline_after_history_replaced(&live_timelines, session_id, &conversation) + .await; + + let timelines = live_timelines.lock().await; + let items = timelines + .get(session_id) + .expect("replacement should retain a user boundary") + .items(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "current-user-text"); + assert_eq!(items[0].text.as_deref(), Some("Live current turn")); + assert!(!items.iter().any(|item| item.id == "stale-live-thinking")); + assert!(!items.iter().any(|item| item.id == "hidden-user-text")); + } + + #[tokio::test] + async fn history_replaced_boundary_prevents_post_compaction_replay_on_reload() { + let session_id = "post-compaction-reload"; + let prior_user = Message::user() + .with_id("prior-user") + .with_text("Earlier turn"); + let prior_assistant = Message::assistant() + .with_id("prior-assistant") + .with_text("Earlier answer"); + let current_user = Message::user() + .with_id("current-user") + .with_text("Current turn"); + let replacement = Conversation::new_unvalidated(vec![ + prior_user.clone(), + prior_assistant.clone(), + current_user.clone(), + ]); + let live_timelines = Arc::new(Mutex::new(HashMap::new())); + reseed_live_timeline_after_history_replaced(&live_timelines, session_id, &replacement) + .await; + + let live_response = assistant_tool_message( + "live-provider-response", + "tool-1", + "Authoritative live thought", + "", + ); + for item in message_to_timeline_items(&live_response, true) { + record_timeline_item(&live_timelines, session_id, item).await; + } + + let persisted_conversation = Conversation::new_unvalidated(vec![ + prior_user, + prior_assistant, + current_user, + with_usage(assistant_tool_message( + "persisted-split-request", + "tool-1", + "Persisted provider-history copy", + "", + )), + tool_response_message("persisted-tool-response", "tool-1"), + ]); + let persisted = conversation_to_timeline_items(&persisted_conversation); + assert_eq!( + timeline_thinking_texts(&persisted), + vec!["Persisted provider-history copy"] + ); + + let overlaid = overlay_live_timeline( + &live_timelines, + session_id, + &persisted_conversation, + persisted, + ) + .await; + + assert!(overlaid.iter().any(|item| item.id == "prior-user-text")); + assert!(overlaid + .iter() + .any(|item| item.id == "prior-assistant-text")); + assert_eq!( + overlaid + .iter() + .filter(|item| item.id == "current-user-text") + .count(), + 1 + ); + assert_eq!( + timeline_thinking_texts(&overlaid), + vec!["Authoritative live thought"] + ); + assert!(overlaid + .iter() + .any(|item| item.id == "live-provider-response-thinking")); + assert!(!overlaid + .iter() + .any(|item| item.id == "persisted-split-request-thinking")); + assert_eq!( + overlaid + .iter() + .filter(|item| item.item_type == "tool" && item.id == "tool-1") + .count(), + 1 + ); + } + + #[test] + fn clear_history_removes_only_the_target_account_session_store() { + let test_root = std::env::temp_dir().join(format!( + "maple-agent-history-clear-{}-{}", + std::process::id(), + unix_ms() + )); + let app_config_dir = test_root.join("app-config"); + let agent_root = app_config_dir.join("agent"); + let account_dir = agent_root.join("accounts/target"); + let other_account_dir = agent_root.join("accounts/other"); + let removed = [account_dir.join("goose/data/session.db")]; + for path in &removed { + write_test_file(path); + } + + let preserved = [ + account_dir.join("config.json"), + account_dir.join("recent_roots.json"), + account_dir.join("goose/config/permissions.json"), + other_account_dir.join("goose/data/session.db"), + agent_root.join("goose-runtime/config/config.yaml"), + app_config_dir.join("proxy_config.json"), + ]; + for path in &preserved { + write_test_file(path); + } + + clear_agent_history(&account_dir).expect("Agent history should be cleared"); + + for path in removed { + assert!(!path.exists(), "history remained at {}", path.display()); + } + for path in preserved { + assert!(path.exists(), "configuration removed at {}", path.display()); + } + + clear_agent_history(&account_dir).expect("clearing missing history should be idempotent"); + let _ = fs::remove_dir_all(test_root); + } + + #[tokio::test] + async fn offline_session_managers_reopen_only_their_account_data() { + let test_root = std::env::temp_dir().join(format!( + "maple-agent-offline-sessions-{}-{}", + std::process::id(), + unix_ms() + )); + let project_dir = test_root.join("project"); + let account_a = test_root.join("accounts/a"); + let account_b = test_root.join("accounts/b"); + fs::create_dir_all(&project_dir).expect("project directory should be created"); + + let manager_a = session_manager_for_account_dir(&account_a) + .expect("account A session manager should open"); + let manager_b = session_manager_for_account_dir(&account_b) + .expect("account B session manager should open"); + let session_a = manager_a + .create_session( + project_dir.clone(), + "Account A chat".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .expect("account A session should be created"); + let session_b = manager_b + .create_session( + project_dir.clone(), + "Account B chat".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .expect("account B session should be created"); + let account_b_only_session = manager_b + .create_session( + project_dir, + "Account B second chat".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .expect("account B second session should be created"); + drop(manager_a); + drop(manager_b); + + let reopened_a = session_manager_for_account_dir(&account_a) + .expect("account A session manager should reopen"); + let reopened_b = session_manager_for_account_dir(&account_b) + .expect("account B session manager should reopen"); + let loaded_a = reopened_a + .get_session(&session_a.id, true) + .await + .expect("account A session should reload"); + let loaded_b = reopened_b + .get_session(&session_b.id, true) + .await + .expect("account B session should reload"); + assert_eq!(loaded_a.name, "Account A chat"); + assert_eq!(loaded_b.name, "Account B chat"); + assert!(reopened_a + .get_session(&account_b_only_session.id, true) + .await + .is_err()); + + reopened_a + .delete_session(&session_a.id) + .await + .expect("account A session should be deleted"); + assert!(reopened_a.list_all_sessions().await.unwrap().is_empty()); + assert_eq!(reopened_b.list_all_sessions().await.unwrap().len(), 2); + + drop(reopened_a); + drop(reopened_b); + let _ = fs::remove_dir_all(test_root); + } + + #[tokio::test] + async fn deletes_only_target_session_runtime_state() { + let test_root = std::env::temp_dir().join(format!( + "maple-agent-session-delete-flow-{}-{}", + std::process::id(), + unix_ms() + )); + let data_dir = test_root.join("goose-data"); + let project_dir = test_root.join("project"); + fs::create_dir_all(&data_dir).expect("Goose data directory should be created"); + fs::create_dir_all(&project_dir).expect("project directory should be created"); + + let session_manager = SessionManager::new(data_dir); + let target = session_manager + .create_session( + project_dir.clone(), + "Target chat".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .expect("target session should be created"); + let survivor = session_manager + .create_session( + project_dir, + "Surviving chat".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .expect("surviving session should be created"); + + let live_timelines = Arc::new(Mutex::new(HashMap::from([ + (target.id.clone(), LiveTimeline::Streaming(Vec::new())), + (survivor.id.clone(), LiveTimeline::Streaming(Vec::new())), + ]))); + let pending_permissions = Arc::new(Mutex::new(HashMap::from([ + ((target.id.clone(), "target-request".to_string()), ()), + ((survivor.id.clone(), "survivor-request".to_string()), ()), + ]))); + delete_persisted_agent_session( + &session_manager, + &pending_permissions, + &live_timelines, + &target.id, + ) + .await + .expect("target session deletion should succeed"); + + assert!(session_manager + .get_session(&target.id, false) + .await + .is_err()); + assert!(session_manager + .get_session(&survivor.id, false) + .await + .is_ok()); + assert!(!live_timelines.lock().await.contains_key(&target.id)); + assert!(live_timelines.lock().await.contains_key(&survivor.id)); + let permissions = pending_permissions.lock().await; + assert!(!permissions + .keys() + .any(|(session_id, _)| session_id == &target.id)); + assert!(permissions + .keys() + .any(|(session_id, _)| session_id == &survivor.id)); + + let _ = fs::remove_dir_all(test_root); + } + + #[tokio::test] + async fn cancelled_turn_rollback_restores_exact_pre_turn_state() { + let test_root = std::env::temp_dir().join(format!( + "maple-agent-cancelled-turn-{}-{}", + std::process::id(), + unix_ms() + )); + let data_dir = test_root.join("goose-data"); + let project_dir = test_root.join("project"); + fs::create_dir_all(&data_dir).expect("Goose data directory should be created"); + fs::create_dir_all(&project_dir).expect("project directory should be created"); + + let session_manager = SessionManager::new(data_dir); + let session = session_manager + .create_session( + project_dir.clone(), + "Cancellation test".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .expect("test session should be created"); + let prior_user = Message::user().with_text("keep me").with_generated_id(); + let prior_assistant = Message::assistant() + .with_text("kept response") + .with_generated_id(); + for message in [&prior_user, &prior_assistant] { + session_manager + .add_message(&session.id, message) + .await + .expect("test message should be persisted"); + } + let prior_live_item = error_item("keep prior live error".to_string()); + let live_timelines = Arc::new(Mutex::new(HashMap::from([( + session.id.clone(), + LiveTimeline::Streaming(vec![prior_live_item.clone()]), + )]))); + + let mut before_turn = session_manager + .get_session(&session.id, true) + .await + .expect("pre-turn session should load"); + let snapshot = AgentTurnSnapshot { + conversation: before_turn + .conversation + .take() + .expect("pre-turn conversation should be loaded"), + autogenerated_title: None, + live_timeline: live_timelines.lock().await.get(&session.id).cloned(), + }; + + let configured_model_name = "configured-after-snapshot"; + session_manager + .update(&session.id) + .model_config( + serde_json::from_value(json!({ + "model_name": configured_model_name, + "context_limit": 200_000, + "temperature": null, + "max_tokens": 8_192, + "toolshim": false, + "toolshim_model": null + })) + .expect("test model configuration should deserialize"), + ) + .goose_mode(GooseMode::Auto) + .apply() + .await + .expect("turn configuration should be persisted"); + + let compacted_history = Message::assistant() + .with_text("replacement compacted history") + .with_generated_id(); + let cancelled_user = Message::user().with_text("discard me").with_generated_id(); + let partial_assistant = Message::assistant() + .with_text("partial response") + .with_generated_id(); + session_manager + .replace_conversation( + &session.id, + &Conversation::new_unvalidated(vec![ + compacted_history, + cancelled_user.clone(), + partial_assistant.clone(), + ]), + ) + .await + .expect("Goose history replacement should be simulated"); + + // Simulate HistoryReplaced clearing the session entry, followed by a + // later current-turn event that has no optimistic user boundary. + let post_history_replaced_item = error_item("post-replacement partial event".to_string()); + live_timelines.lock().await.insert( + session.id.clone(), + LiveTimeline::Streaming(vec![post_history_replaced_item.clone()]), + ); + rollback_cancelled_agent_turn(&session_manager, &live_timelines, &session.id, &snapshot) + .await + .expect("cancelled turn should be discarded"); + // Restoring the exact snapshot is idempotent, including when + // cancellation raced before Goose persisted any part of the turn. + rollback_cancelled_agent_turn(&session_manager, &live_timelines, &session.id, &snapshot) + .await + .expect("repeated snapshot restoration should be a no-op"); + + let reloaded = session_manager + .get_session(&session.id, true) + .await + .expect("test session should reload"); + let conversation = reloaded + .conversation + .as_ref() + .expect("test session should have a conversation"); + assert_eq!(conversation, &snapshot.conversation); + assert_eq!(reloaded.name, "Cancellation test"); + assert_eq!(reloaded.goose_mode, GooseMode::Auto); + assert_eq!( + reloaded + .model_config + .as_ref() + .map(|config| config.model_name.as_str()), + Some(configured_model_name) + ); + let restored_live_timeline = live_timelines.lock().await.get(&session.id).cloned(); + assert_eq!(restored_live_timeline, snapshot.live_timeline); + assert!(!live_timelines + .lock() + .await + .get(&session.id) + .is_some_and(|timeline| timeline + .items() + .iter() + .any(|item| item.id == post_history_replaced_item.id))); + let persisted_timeline = conversation_to_timeline_items(conversation); + let overlaid_timeline = overlay_live_timeline( + &live_timelines, + &session.id, + conversation, + persisted_timeline, + ) + .await; + assert!(overlaid_timeline + .iter() + .any(|item| item.id == prior_live_item.id)); + assert!(!overlaid_timeline.iter().any(|item| { + item.text.as_deref().is_some_and(|text| { + text.contains("discard me") || text.contains("partial response") + }) + })); + + let first_turn_session = session_manager + .create_session( + project_dir, + DEFAULT_AGENT_SESSION_TITLE.to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .expect("first-turn session should be created"); + let mut first_turn_before = session_manager + .get_session(&first_turn_session.id, true) + .await + .expect("first-turn snapshot should load"); + let first_turn_snapshot = AgentTurnSnapshot { + conversation: first_turn_before + .conversation + .take() + .expect("empty first-turn conversation should be loaded"), + autogenerated_title: Some(first_turn_before.name.clone()), + live_timeline: live_timelines + .lock() + .await + .get(&first_turn_session.id) + .cloned(), + }; + session_manager + .update(&first_turn_session.id) + .system_generated_name("discarded first prompt".to_string()) + .apply() + .await + .expect("first prompt should generate a title"); + let first_turn_message = Message::user() + .with_text("discarded first prompt") + .with_generated_id(); + session_manager + .add_message(&first_turn_session.id, &first_turn_message) + .await + .expect("first-turn message should be persisted"); + live_timelines.lock().await.insert( + first_turn_session.id.clone(), + LiveTimeline::Streaming(vec![error_item("first-turn partial event".to_string())]), + ); + rollback_cancelled_agent_turn( + &session_manager, + &live_timelines, + &first_turn_session.id, + &first_turn_snapshot, + ) + .await + .expect("cancelled first turn should be discarded"); + let first_turn_reloaded = session_manager + .get_session(&first_turn_session.id, true) + .await + .expect("first-turn session should reload"); + assert_eq!(first_turn_reloaded.message_count, 0); + assert_eq!(first_turn_reloaded.name, DEFAULT_AGENT_SESSION_TITLE); + assert_eq!( + first_turn_reloaded.conversation.as_ref(), + Some(&first_turn_snapshot.conversation) + ); + assert!(!live_timelines + .lock() + .await + .contains_key(&first_turn_session.id)); + + let _ = fs::remove_dir_all(test_root); + } + + #[tokio::test] + async fn detects_active_run_for_session() { + let mut active_runs = HashMap::new(); + let task_handle = tauri::async_runtime::spawn(async {}); + active_runs.insert( + "run-1".to_string(), + ActiveAgentRun { + token: CancellationToken::new(), + session_id: "session-1".to_string(), + task_handle, + }, + ); + + assert!(has_active_session_run(&active_runs, "session-1")); + assert!(!has_active_session_run(&active_runs, "session-2")); + } + + #[test] + fn account_scopes_are_deterministic_isolated_and_opaque() { + let first = account_scope("user-123").expect("account ID should be valid"); + assert_eq!(first, account_scope(" user-123 ").unwrap()); + assert_ne!(first, account_scope("user-456").unwrap()); + assert_eq!(first.len(), 64); + assert!(!first.contains("user-123")); + } + + #[test] + fn rejects_wrong_runtime_account_scope() { + let first = account_scope("first-user").unwrap(); + let second = account_scope("second-user").unwrap(); + assert!(ensure_account_scope(&first, &first).is_ok()); + assert!(ensure_account_scope(&first, &second).is_err()); + } + + #[tokio::test] + async fn rejects_operations_captured_before_account_clear() { + let state = AgentRuntimeState::new(); + let scope = account_scope("user-to-clear").unwrap(); + let stale_generation = account_generation(&state, &scope).await; + + let current_generation = advance_account_generation(&state, &scope).await; + + assert!(ensure_account_generation(&state, &scope, stale_generation) + .await + .is_err()); + assert!( + ensure_account_generation(&state, &scope, current_generation) + .await + .is_ok() + ); + } + + #[test] + fn run_ids_are_unique() { + let ids = (0..10_000) + .map(|_| next_run_id()) + .collect::>(); + assert_eq!(ids.len(), 10_000); + } + + #[tokio::test] + async fn forced_task_shutdown_joins_aborted_task() { + struct DropFlag(Arc); + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (started_tx, started_rx) = oneshot::channel(); + let task_dropped = Arc::clone(&dropped); + let task = tauri::async_runtime::spawn(async move { + let _drop_flag = DropFlag(task_dropped); + let _ = started_tx.send(()); + futures_util::future::pending::<()>().await; + }); + started_rx.await.unwrap(); + + join_agent_tasks(vec![task], std::time::Duration::from_millis(1)).await; + + assert!(dropped.load(Ordering::SeqCst)); + } + + #[test] + fn session_title_collapses_whitespace_and_bounds_unicode() { + assert_eq!( + session_title_from_prompt(" inspect\n\tthis repo "), + "inspect this repo" + ); + + let title = session_title_from_prompt(&"🙂 ".repeat(100)); + assert!(title.chars().count() <= MAX_AGENT_SESSION_TITLE_CHARS); + assert!(title.ends_with('…')); + assert!(!title.contains(" ")); + } + + #[tokio::test] + async fn cancelled_permission_is_not_registered() { + let pending = Arc::new(Mutex::new(HashMap::new())); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + assert!( + !register_pending_permission(&pending, "request-1", "session-1", &cancel_token).await + ); + assert!(pending.lock().await.is_empty()); + } + + #[tokio::test] + async fn pending_permission_ids_are_scoped_by_session() { + let pending = Arc::new(Mutex::new(HashMap::from([ + (("session-1".to_string(), "shared-request".to_string()), ()), + (("session-2".to_string(), "shared-request".to_string()), ()), + ]))); + + let selected = pending_permissions_for_sessions(&pending, &["session-1".to_string()]).await; + + assert_eq!( + selected, + vec![("shared-request".to_string(), "session-1".to_string())] + ); + assert_eq!(pending.lock().await.len(), 2); + } + + #[test] + fn coalesces_tool_request_and_response_for_loaded_sessions() { + let request = AgentTimelineItem { + id: "functions.shell:7".to_string(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: Some("shell".to_string()), + text: Some("listing project root".to_string()), + status: Some("running".to_string()), + input: Some(json!({ "command": "ls -la" })), + output: None, + created_ms: 1000, + merge: "replace".to_string(), + }; + let response = AgentTimelineItem { + id: "functions.shell:7".to_string(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: None, + text: None, + status: Some("completed".to_string()), + input: None, + output: Some(json!({ "text": "ok" })), + created_ms: 2000, + merge: "replace".to_string(), + }; + + let items = coalesce_timeline_items(vec![request, response]); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "functions.shell:7"); + assert_eq!(items[0].title.as_deref(), Some("shell")); + assert_eq!(items[0].text.as_deref(), Some("listing project root")); + assert_eq!(items[0].status.as_deref(), Some("completed")); + assert_eq!(items[0].input, Some(json!({ "command": "ls -la" }))); + assert_eq!(items[0].output, Some(json!({ "text": "ok" }))); + } + + #[test] + fn tool_error_preserves_request_title_for_provider_generated_id() { + let id = "chatcmpl-tool-123"; + let request = AgentTimelineItem { + id: id.to_string(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: Some("shell".to_string()), + text: None, + status: Some("running".to_string()), + input: Some(json!({ "command": "false" })), + output: None, + created_ms: 1000, + merge: "replace".to_string(), + }; + let response = goose::conversation::message::ToolResponse { + id: id.to_string(), + tool_result: Ok(rmcp::model::CallToolResult::error(vec![ + rmcp::model::Content::text("command failed"), + ])), + metadata: None, + }; + let response = tool_response_item(&response, 2000); + assert_eq!(response.status.as_deref(), Some("failed")); + assert!(response.title.is_none()); + + let merged = coalesce_timeline_items(vec![request, response]); + assert_eq!(merged[0].title.as_deref(), Some("shell")); + assert_eq!(merged[0].status.as_deref(), Some("failed")); + } + + #[test] + fn system_notification_omits_structured_data_and_bounds_message() { + let notification = SystemNotificationContent { + notification_type: SystemNotificationType::InlineMessage, + msg: "x".repeat(600), + data: Some(json!({ "raw": "must-not-render" })), + }; + + let item = system_notification_item("message", 0, ¬ification, 1000); + + assert_eq!(item.title.as_deref(), Some("Agent notice")); + assert_eq!(item.text.as_ref().unwrap().chars().count(), 501); + assert!(item.text.as_ref().unwrap().ends_with('…')); + assert!(item.output.is_none()); + } + + #[test] + fn progress_notification_has_stable_title() { + let notification = SystemNotificationContent { + notification_type: SystemNotificationType::ProgressMessage, + msg: "Loading...".to_string(), + data: None, + }; + + let item = system_notification_item("message", 0, ¬ification, 1000); + + assert_eq!(item.title.as_deref(), Some("Progress")); + assert_eq!(item.text.as_deref(), Some("Loading...")); + } + + #[test] + fn timeline_text_is_bounded_by_characters() { + assert_eq!(bounded_timeline_text("éclair", 2), "éc…"); + assert_eq!(bounded_timeline_text("short", 10), "short"); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 1749ede5..1289cf14 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ use tauri::Emitter; use tauri_plugin_deep_link::DeepLinkExt; +#[cfg(desktop)] +mod agent; mod pdf_extractor; mod proxy; // TTS is available on desktop and iOS (not Android) @@ -9,11 +11,40 @@ mod tts; #[cfg(desktop)] #[tauri::command] -fn restart_for_update(app_handle: tauri::AppHandle) { +async fn restart_for_update(app_handle: tauri::AppHandle) -> Result<(), String> { log::info!("User requested restart for update"); + agent::shutdown_agent_runtime(&app_handle).await?; app_handle.restart(); } +#[cfg(desktop)] +fn handle_desktop_run_event(app_handle: &tauri::AppHandle, event: tauri::RunEvent) { + let tauri::RunEvent::ExitRequested { code, api, .. } = event else { + return; + }; + + // Update restart is explicitly drained by restart_for_update. Tauri does + // not allow restart ExitRequested events to be prevented. + if code == Some(tauri::RESTART_EXIT_CODE) || AGENT_EXIT_CLEANUP_COMPLETE.load(Ordering::SeqCst) + { + return; + } + + api.prevent_exit(); + if AGENT_EXIT_CLEANUP_STARTED.swap(true, Ordering::SeqCst) { + return; + } + + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = agent::shutdown_agent_runtime(&app_handle).await { + log::error!("Failed to stop Agent Mode during app exit: {error}"); + } + AGENT_EXIT_CLEANUP_COMPLETE.store(true, Ordering::SeqCst); + app_handle.exit(code.unwrap_or_default()); + }); +} + // This handles incoming deep links fn handle_deep_link_event(url: &str, app: &tauri::AppHandle) { log::info!("[Deep Link] Received: {url}"); @@ -36,11 +67,31 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .manage(agent::AgentRuntimeState::new()) .manage(proxy::ProxyState::new()) .manage(tts::TTSState::new()) .invoke_handler(tauri::generate_handler![ + agent::agent_get_runtime_status, + agent::agent_start_runtime, + agent::agent_stop_runtime, + agent::agent_restart_runtime, + agent::agent_load_config, + agent::agent_save_config, + agent::agent_list_recent_project_roots, + agent::agent_save_recent_project_root, + agent::agent_create_session, + agent::agent_list_sessions, + agent::agent_load_session, + agent::agent_delete_session, + agent::agent_send_message, + agent::agent_cancel_run, + agent::agent_permission_respond, + agent::agent_clear_user_history, + agent::agent_clear_user_data, proxy::start_proxy, proxy::stop_proxy, + proxy::stop_and_reset_proxy, proxy::get_proxy_status, proxy::load_proxy_config, proxy::save_proxy_settings, @@ -304,6 +355,12 @@ pub fn run() { }) .plugin(tauri_plugin_updater::Builder::new().build()); + #[cfg(desktop)] + app.build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(handle_desktop_run_event); + + #[cfg(not(desktop))] app.run(tauri::generate_context!()) .expect("error while running tauri application"); } @@ -319,6 +376,10 @@ use std::sync::Mutex; #[cfg(desktop)] static UPDATE_DOWNLOADED: AtomicBool = AtomicBool::new(false); #[cfg(desktop)] +static AGENT_EXIT_CLEANUP_STARTED: AtomicBool = AtomicBool::new(false); +#[cfg(desktop)] +static AGENT_EXIT_CLEANUP_COMPLETE: AtomicBool = AtomicBool::new(false); +#[cfg(desktop)] static CURRENT_VERSION: Lazy> = Lazy::new(|| Mutex::new(String::new())); /// Check for updates silently in the background diff --git a/frontend/src-tauri/src/proxy.rs b/frontend/src-tauri/src/proxy.rs index 0eb6345c..ce01dcca 100644 --- a/frontend/src-tauri/src/proxy.rs +++ b/frontend/src-tauri/src/proxy.rs @@ -1,12 +1,23 @@ use anyhow::{anyhow, Result}; use maple_proxy::{create_app, Config}; use serde::{Deserialize, Serialize}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::path::Path; use std::path::PathBuf; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, State}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; use tokio::sync::Mutex; +#[cfg(any(target_os = "macos", target_os = "linux"))] +const MAPLE_APP_IDENTIFIER: &str = "cloud.opensecret.maple"; +#[cfg(any(target_os = "macos", target_os = "linux"))] +static LEGACY_CONFIG_MIGRATION_COUNTER: AtomicU64 = AtomicU64::new(0); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxyConfig { pub host: String, @@ -50,6 +61,7 @@ pub struct ProxyState { handle: Arc>>>, config: Arc>, running: Arc>, + lifecycle: Arc>, } impl ProxyState { @@ -58,6 +70,15 @@ impl ProxyState { handle: Arc::new(Mutex::new(None)), config: Arc::new(Mutex::new(ProxyConfig::default())), running: Arc::new(Mutex::new(false)), + lifecycle: Arc::new(Mutex::new(())), + } + } + + pub async fn status(&self) -> ProxyStatus { + ProxyStatus { + running: *self.running.lock().await, + config: self.config.lock().await.clone(), + error: None, } } } @@ -65,10 +86,11 @@ impl ProxyState { // On Windows the proxy config lives in the roaming %APPDATA% profile, so a // plaintext api_key could sync across machines in a domain/AAD environment. // Store it in Windows Credential Manager instead and keep it out of the JSON. +// The Tauri identifier scopes both the config directory and credential entry, +// so managed workspace builds cannot read or overwrite production's key. The +// production identifier remains the legacy service name, requiring no migration. // macOS/Linux keep their local plaintext-with-0o600 behavior unchanged. #[cfg(target_os = "windows")] -const KEYRING_SERVICE: &str = "cloud.opensecret.maple"; -#[cfg(target_os = "windows")] const KEYRING_USER: &str = "proxy_api_key"; /// Persist the API key in Windows Credential Manager. An empty key clears the @@ -77,10 +99,16 @@ const KEYRING_USER: &str = "proxy_api_key"; /// and `Err` when a clear was requested but the stale credential could not be /// removed (caller must not scrub the JSON, or the old key would be resurrected). #[cfg(target_os = "windows")] -fn store_api_key(key: &str) -> Result { - let entry = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) { +fn store_api_key(app_handle: &AppHandle, key: &str) -> Result { + let service = app_handle.config().identifier.clone(); + let entry = match keyring::Entry::new(&service, KEYRING_USER) { Ok(entry) => entry, Err(e) => { + if key.is_empty() { + return Err(anyhow!( + "Failed to access Credential Manager while clearing the API key: {e}" + )); + } log::warn!("Credential Manager unavailable, keeping plaintext config: {e}"); return Ok(false); } @@ -114,8 +142,9 @@ fn store_api_key(key: &str) -> Result { /// - `Ok(Some(key))` — Credential Manager is available (`key` may be empty). /// - `Ok(None)` — unavailable; caller should fall back to the JSON value. #[cfg(target_os = "windows")] -fn load_api_key() -> Result> { - let entry = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) { +fn load_api_key(app_handle: &AppHandle) -> Result> { + let service = app_handle.config().identifier.clone(); + let entry = match keyring::Entry::new(&service, KEYRING_USER) { Ok(entry) => entry, Err(_) => return Ok(None), }; @@ -137,6 +166,15 @@ pub async fn start_proxy( app_handle: AppHandle, state: State<'_, ProxyState>, config: ProxyConfig, +) -> Result { + let _lifecycle_guard = state.lifecycle.lock().await; + start_proxy_inner(app_handle, &state, config).await +} + +async fn start_proxy_inner( + app_handle: AppHandle, + state: &ProxyState, + config: ProxyConfig, ) -> Result { log::info!( "Starting proxy on {}:{} (cors={}, auto_start={})", @@ -149,14 +187,10 @@ pub async fn start_proxy( // Check if proxy is already running let mut running = state.running.lock().await; if *running { - return Err("Proxy is already running".to_string()); + drop(running); + return Ok(state.status().await); } - // Update config - let mut stored_config = state.config.lock().await; - *stored_config = config.clone(); - drop(stored_config); // Release config lock early - // Use backend URL from config or fall back to production let backend_url = config .backend_url @@ -184,7 +218,17 @@ pub async fn start_proxy( } }; - // Create the app + // Starting successfully means the exact credential/configuration is also + // durable. In particular, do not hide Credential Manager or disk failures + // behind a running in-memory proxy that will change after restart. + save_proxy_config(&app_handle, &config) + .await + .map_err(|error| format!("Failed to save proxy config: {error}"))?; + *state.config.lock().await = config.clone(); + + // maple-proxy owns the OpenAI-compatible transport, including the shared + // 50 MiB request limit needed by Goose's image tool. Provider responses are + // passed through unchanged. let app = create_app(proxy_config); // Spawn the proxy server @@ -202,11 +246,6 @@ pub async fn start_proxy( *running = true; - // Save config to disk - if let Err(e) = save_proxy_config(&app_handle, &config).await { - log::error!("Failed to save proxy config: {e}"); - } - Ok(ProxyStatus { running: true, config, @@ -216,19 +255,27 @@ pub async fn start_proxy( #[tauri::command] pub async fn stop_proxy(state: State<'_, ProxyState>) -> Result { + let _lifecycle_guard = state.lifecycle.lock().await; + stop_proxy_inner(&state).await +} + +async fn stop_proxy_inner(state: &ProxyState) -> Result { log::info!("Stopping proxy"); let mut running = state.running.lock().await; if !*running { - return Err("Proxy is not running".to_string()); + drop(running); + return Ok(state.status().await); } // Abort the proxy task - let mut handle_guard = state.handle.lock().await; - if let Some(handle) = handle_guard.take() { + let handle = state.handle.lock().await.take(); + if let Some(handle) = handle { handle.abort(); + // Await cancellation while lifecycle serialization is still held so a + // subsequent start cannot race the old listener's teardown. + let _ = handle.await; } - drop(handle_guard); // Release handle lock before taking config lock to avoid deadlock *running = false; @@ -245,30 +292,74 @@ pub async fn stop_proxy(state: State<'_, ProxyState>) -> Result) -> Result { - let running = *state.running.lock().await; - let config = state.config.lock().await.clone(); - - Ok(ProxyStatus { - running, - config, - error: None, - }) + let _lifecycle_guard = state.lifecycle.lock().await; + Ok(state.status().await) } #[tauri::command] -pub async fn load_proxy_config(app_handle: AppHandle) -> Result { +pub async fn load_proxy_config( + app_handle: AppHandle, + state: State<'_, ProxyState>, +) -> Result { + let _lifecycle_guard = state.lifecycle.lock().await; load_saved_proxy_config(&app_handle) .await .map_err(|e| format!("Failed to load proxy config: {e}")) } #[tauri::command] -pub async fn save_proxy_settings(app_handle: AppHandle, config: ProxyConfig) -> Result<(), String> { +pub async fn save_proxy_settings( + app_handle: AppHandle, + state: State<'_, ProxyState>, + config: ProxyConfig, +) -> Result<(), String> { + let _lifecycle_guard = state.lifecycle.lock().await; save_proxy_config(&app_handle, &config) .await .map_err(|e| format!("Failed to save proxy config: {e}")) } +#[tauri::command] +pub async fn stop_and_reset_proxy( + app_handle: AppHandle, + state: State<'_, ProxyState>, +) -> Result { + let _lifecycle_guard = state.lifecycle.lock().await; + stop_proxy_inner(&state).await?; + + // Clear account-bound state without discarding app/workspace routing such + // as the managed proxy port or backend URL. + let mut config = match load_saved_proxy_config(&app_handle).await { + Ok(config) => config, + Err(_) => state.config.lock().await.clone(), + }; + config.api_key.clear(); + config.enabled = false; + config.auto_start = false; + save_proxy_config(&app_handle, &config) + .await + .map_err(|error| format!("Failed to reset proxy config: {error}"))?; + + #[cfg(any(target_os = "macos", target_os = "linux"))] + if app_handle.config().identifier == MAPLE_APP_IDENTIFIER { + scrub_legacy_proxy_config( + &legacy_proxy_config_path() + .map_err(|error| format!("Failed to locate legacy proxy config: {error}"))?, + InvalidLegacyConfigPolicy::Remove, + ) + .await + .map_err(|error| format!("Failed to reset legacy proxy config: {error}"))?; + } + + *state.config.lock().await = config.clone(); + + Ok(ProxyStatus { + running: false, + config, + error: None, + }) +} + #[tauri::command] pub async fn test_proxy_port(host: String, port: u16) -> Result { // Try to bind to the address to check if it's available @@ -285,40 +376,470 @@ pub async fn test_proxy_port(host: String, port: u16) -> Result { } } -// Helper functions for config persistence. -// Windows uses the Tauri-managed app_config_dir() (%APPDATA%); macOS/Linux keep -// the historical ~/.config/maple/ location. The Windows API key is additionally -// stored in Credential Manager rather than plaintext (see store/load_api_key). +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proxy_request_body_limit_remains_50_mib() { + assert_eq!(maple_proxy::MAX_PROXY_REQUEST_BODY_BYTES, 50 * 1024 * 1024); + } + + #[tokio::test] + async fn stop_waits_for_aborted_server_task() { + struct DropFlag(Arc); + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + let state = ProxyState::new(); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task_dropped = Arc::clone(&dropped); + let task = tokio::spawn(async move { + let _drop_flag = DropFlag(task_dropped); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }); + started_rx.await.unwrap(); + *state.handle.lock().await = Some(task); + *state.running.lock().await = true; + + let status = stop_proxy_inner(&state).await.unwrap(); + + assert!(!status.running); + assert!(dropped.load(std::sync::atomic::Ordering::SeqCst)); + assert!(state.handle.lock().await.is_none()); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn config_migration_test_dir() -> PathBuf { + let counter = LEGACY_CONFIG_MIGRATION_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "maple-proxy-config-migration-{}-{counter}", + std::process::id() + )) + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[tokio::test] + async fn migrates_legacy_config_once_and_scrubs_source() { + use std::os::unix::fs::PermissionsExt; + + let root = config_migration_test_dir(); + let legacy_path = root.join("legacy/proxy_config.json"); + let target_path = root.join("app/proxy_config.json"); + tokio::fs::create_dir_all(legacy_path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::create_dir_all(target_path.parent().unwrap()) + .await + .unwrap(); + + let original = ProxyConfig { + host: "127.0.0.2".to_string(), + port: 8765, + api_key: "legacy-key".to_string(), + enabled: true, + enable_cors: false, + backend_url: Some("https://example.invalid".to_string()), + auto_start: true, + }; + tokio::fs::write(&legacy_path, serde_json::to_vec(&original).unwrap()) + .await + .unwrap(); + + migrate_legacy_proxy_config(&legacy_path, &target_path) + .await + .unwrap(); + assert!(legacy_path.exists()); + let migrated: ProxyConfig = + serde_json::from_slice(&tokio::fs::read(&target_path).await.unwrap()).unwrap(); + assert_eq!(migrated.port, 8765); + assert_eq!(migrated.api_key, "legacy-key"); + assert!(migrated.enabled); + assert!(migrated.auto_start); + assert_eq!( + tokio::fs::metadata(&target_path) + .await + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let scrubbed: ProxyConfig = + serde_json::from_slice(&tokio::fs::read(&legacy_path).await.unwrap()).unwrap(); + assert_eq!(scrubbed.host, original.host); + assert_eq!(scrubbed.port, original.port); + assert_eq!(scrubbed.enable_cors, original.enable_cors); + assert_eq!(scrubbed.backend_url, original.backend_url); + assert!(scrubbed.api_key.is_empty()); + assert!(!scrubbed.enabled); + assert!(!scrubbed.auto_start); + assert_eq!( + tokio::fs::metadata(&legacy_path) + .await + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let changed = ProxyConfig { + port: 9999, + api_key: "reintroduced-key".to_string(), + enabled: true, + auto_start: true, + ..original + }; + tokio::fs::write(&legacy_path, serde_json::to_vec(&changed).unwrap()) + .await + .unwrap(); + migrate_legacy_proxy_config(&legacy_path, &target_path) + .await + .unwrap(); + let still_migrated: ProxyConfig = + serde_json::from_slice(&tokio::fs::read(&target_path).await.unwrap()).unwrap(); + assert_eq!(still_migrated.port, 8765); + let rescrubbed: ProxyConfig = + serde_json::from_slice(&tokio::fs::read(&legacy_path).await.unwrap()).unwrap(); + assert_eq!(rescrubbed.port, 9999); + assert!(rescrubbed.api_key.is_empty()); + assert!(!rescrubbed.enabled); + assert!(!rescrubbed.auto_start); + + tokio::fs::remove_dir_all(root).await.unwrap(); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[tokio::test] + async fn skips_invalid_legacy_config_without_blocking_fresh_state() { + let root = config_migration_test_dir(); + let legacy_path = root.join("legacy/proxy_config.json"); + let target_path = root.join("app/proxy_config.json"); + tokio::fs::create_dir_all(legacy_path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::create_dir_all(target_path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&legacy_path, b"not valid JSON") + .await + .unwrap(); + + migrate_legacy_proxy_config(&legacy_path, &target_path) + .await + .unwrap(); + assert!(!target_path.exists()); + assert!(legacy_path.exists()); + + tokio::fs::remove_dir_all(root).await.unwrap(); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[tokio::test] + async fn explicit_legacy_reset_removes_invalid_config() { + let root = config_migration_test_dir(); + let legacy_path = root.join("legacy/proxy_config.json"); + tokio::fs::create_dir_all(legacy_path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&legacy_path, b"not valid JSON") + .await + .unwrap(); + + scrub_legacy_proxy_config(&legacy_path, InvalidLegacyConfigPolicy::Remove) + .await + .unwrap(); + + assert!(!legacy_path.exists()); + tokio::fs::remove_dir_all(root).await.unwrap(); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[tokio::test] + async fn explicit_legacy_reset_surfaces_valid_scrub_failure() { + use std::os::unix::fs::PermissionsExt; + + let root = config_migration_test_dir(); + let legacy_dir = root.join("legacy"); + let legacy_path = legacy_dir.join("proxy_config.json"); + let target_path = root.join("app/proxy_config.json"); + tokio::fs::create_dir_all(&legacy_dir).await.unwrap(); + tokio::fs::create_dir_all(target_path.parent().unwrap()) + .await + .unwrap(); + let config = ProxyConfig { + api_key: "credential-that-must-not-survive".to_string(), + enabled: true, + auto_start: true, + ..ProxyConfig::default() + }; + tokio::fs::write(&legacy_path, serde_json::to_vec(&config).unwrap()) + .await + .unwrap(); + tokio::fs::write( + &target_path, + serde_json::to_vec(&ProxyConfig::default()).unwrap(), + ) + .await + .unwrap(); + tokio::fs::set_permissions(&legacy_dir, std::fs::Permissions::from_mode(0o500)) + .await + .unwrap(); + + // An authoritative app-specific config keeps ordinary loads working. + migrate_legacy_proxy_config(&legacy_path, &target_path) + .await + .unwrap(); + let result = + scrub_legacy_proxy_config(&legacy_path, InvalidLegacyConfigPolicy::Remove).await; + + tokio::fs::set_permissions(&legacy_dir, std::fs::Permissions::from_mode(0o700)) + .await + .unwrap(); + assert!(result.is_err()); + let unchanged: ProxyConfig = + serde_json::from_slice(&tokio::fs::read(&legacy_path).await.unwrap()).unwrap(); + assert_eq!(unchanged.api_key, config.api_key); + tokio::fs::remove_dir_all(root).await.unwrap(); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[tokio::test] + async fn completed_migration_is_not_blocked_by_legacy_scrub_failure() { + use std::os::unix::fs::PermissionsExt; + + let root = config_migration_test_dir(); + let legacy_dir = root.join("legacy"); + let legacy_path = legacy_dir.join("proxy_config.json"); + let target_path = root.join("app/proxy_config.json"); + tokio::fs::create_dir_all(&legacy_dir).await.unwrap(); + tokio::fs::create_dir_all(target_path.parent().unwrap()) + .await + .unwrap(); + let config = ProxyConfig { + api_key: "legacy-key".to_string(), + enabled: true, + auto_start: true, + ..ProxyConfig::default() + }; + tokio::fs::write(&legacy_path, serde_json::to_vec(&config).unwrap()) + .await + .unwrap(); + tokio::fs::set_permissions(&legacy_dir, std::fs::Permissions::from_mode(0o500)) + .await + .unwrap(); + + let result = migrate_legacy_proxy_config(&legacy_path, &target_path).await; + + tokio::fs::set_permissions(&legacy_dir, std::fs::Permissions::from_mode(0o700)) + .await + .unwrap(); + result.unwrap(); + let migrated: ProxyConfig = + serde_json::from_slice(&tokio::fs::read(&target_path).await.unwrap()).unwrap(); + assert_eq!(migrated.api_key, config.api_key); + let unchanged: ProxyConfig = + serde_json::from_slice(&tokio::fs::read(&legacy_path).await.unwrap()).unwrap(); + assert_eq!(unchanged.api_key, config.api_key); + tokio::fs::remove_dir_all(root).await.unwrap(); + } +} + +// Keep proxy state scoped to the Tauri application identifier on every platform. +// Production macOS/Linux installs copy the historical ~/.config/maple config on +// first use; workspace builds have unique identifiers and remain isolated. async fn get_config_path(app_handle: &AppHandle) -> Result { - let app_dir = if cfg!(target_os = "windows") { - // Resolves to %APPDATA%\cloud.opensecret.maple\ (Roaming). - app_handle - .path() - .app_config_dir() - .map_err(|e| anyhow!("Failed to resolve app config dir: {e}"))? - } else { - // macOS/Linux: ~/.config/maple/ — unchanged for byte-identical behavior. - let app_name = "maple"; - let home_dir = - std::env::var("HOME").map_err(|_| anyhow!("Failed to get home directory"))?; - PathBuf::from(home_dir).join(".config").join(app_name) + let app_dir = app_handle + .path() + .app_config_dir() + .map_err(|e| anyhow!("Failed to resolve app config dir: {e}"))?; + tokio::fs::create_dir_all(&app_dir).await?; + let path = app_dir.join("proxy_config.json"); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + if app_handle.config().identifier == MAPLE_APP_IDENTIFIER { + migrate_legacy_proxy_config(&legacy_proxy_config_path()?, &path).await?; + } + + Ok(path) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn legacy_proxy_config_path() -> Result { + let home = std::env::var("HOME").map_err(|_| anyhow!("Failed to get home directory"))?; + Ok(PathBuf::from(home) + .join(".config") + .join("maple") + .join("proxy_config.json")) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +async fn migrate_legacy_proxy_config(legacy_path: &Path, target_path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + if !tokio::fs::try_exists(legacy_path).await? { + return Ok(()); + } + + if tokio::fs::try_exists(target_path).await? { + if let Err(error) = + scrub_legacy_proxy_config(legacy_path, InvalidLegacyConfigPolicy::Preserve).await + { + // The app-specific config is already authoritative. Legacy cleanup + // is retried on later loads and is mandatory during explicit reset. + log::warn!( + "Unable to scrub legacy proxy credentials from {}: {error}", + legacy_path.display() + ); + } + return Ok(()); + } + + let contents = match tokio::fs::read(legacy_path).await { + Ok(contents) => contents, + Err(error) => { + log::warn!( + "Skipping unreadable legacy proxy config {}: {error}", + legacy_path.display() + ); + return Ok(()); + } }; + if let Err(error) = serde_json::from_slice::(&contents) { + log::warn!( + "Skipping invalid legacy proxy config {}: {error}", + legacy_path.display() + ); + return Ok(()); + } - // Ensure directory exists - tokio::fs::create_dir_all(&app_dir).await?; + let counter = LEGACY_CONFIG_MIGRATION_COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_name = format!(".proxy_config.migrate-{}-{counter}.tmp", std::process::id()); + let temp_path = target_path.with_file_name(temp_name); + + let migration = async { + let mut temp = tokio::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temp_path) + .await?; + temp.write_all(&contents).await?; + temp.sync_all().await?; + tokio::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600)).await?; + + match tokio::fs::hard_link(&temp_path, target_path).await { + Ok(()) => log::info!( + "Migrated proxy configuration into {}", + target_path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + Ok::<(), std::io::Error>(()) + } + .await; + + let _ = tokio::fs::remove_file(&temp_path).await; + migration?; - Ok(app_dir.join("proxy_config.json")) + // Keep a credential-free rollback fallback at the legacy path. The + // app-specific copy is already authoritative, so cleanup failure is + // best-effort here and remains a hard error during explicit reset. + if let Err(error) = + scrub_legacy_proxy_config(legacy_path, InvalidLegacyConfigPolicy::Preserve).await + { + log::warn!( + "Unable to scrub legacy proxy credentials from {}: {error}", + legacy_path.display() + ); + } + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[derive(Clone, Copy)] +enum InvalidLegacyConfigPolicy { + Preserve, + Remove, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +async fn scrub_legacy_proxy_config( + path: &Path, + invalid_policy: InvalidLegacyConfigPolicy, +) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let contents = match tokio::fs::read(path).await { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + let mut config: ProxyConfig = match serde_json::from_slice(&contents) { + Ok(config) => config, + Err(error) => { + log::warn!( + "Unable to parse legacy proxy config {} while removing credentials: {error}", + path.display() + ); + if matches!(invalid_policy, InvalidLegacyConfigPolicy::Remove) { + tokio::fs::remove_file(path).await?; + } + return Ok(()); + } + }; + + let already_scrubbed = config.api_key.is_empty() && !config.enabled && !config.auto_start; + let owner_only = tokio::fs::metadata(path).await?.permissions().mode() & 0o777 == 0o600; + if already_scrubbed && owner_only { + return Ok(()); + } + + config.api_key.clear(); + config.enabled = false; + config.auto_start = false; + + let counter = LEGACY_CONFIG_MIGRATION_COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_name = format!(".proxy_config.scrub-{}-{counter}.tmp", std::process::id()); + let temp_path = path.with_file_name(temp_name); + let rewrite = async { + let mut temp = tokio::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temp_path) + .await?; + temp.write_all(&serde_json::to_vec_pretty(&config)?).await?; + temp.sync_all().await?; + tokio::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600)).await?; + tokio::fs::rename(&temp_path, path).await?; + Ok::<(), anyhow::Error>(()) + } + .await; + + if rewrite.is_err() { + let _ = tokio::fs::remove_file(&temp_path).await; + } + rewrite } async fn save_proxy_config(app_handle: &AppHandle, config: &ProxyConfig) -> Result<()> { let path = get_config_path(app_handle).await?; // On Windows, move the API key into Credential Manager and scrub it from - // the JSON (the config dir is the roaming profile). Other platforms keep - // the existing plaintext-in-JSON behavior unchanged. + // the JSON (the config dir is the roaming profile). Other platforms retain + // the existing owner-only JSON behavior. #[cfg(target_os = "windows")] let json = { - let scrubbed = match store_api_key(&config.api_key) { + let scrubbed = match store_api_key(app_handle, &config.api_key) { Ok(true) => ProxyConfig { api_key: String::new(), ..config.clone() @@ -354,7 +875,7 @@ async fn save_proxy_config(app_handle: &AppHandle, config: &ProxyConfig) -> Resu Ok(()) } -async fn load_saved_proxy_config(app_handle: &AppHandle) -> Result { +pub async fn load_saved_proxy_config(app_handle: &AppHandle) -> Result { let path = get_config_path(app_handle).await?; if !path.exists() { @@ -368,7 +889,7 @@ async fn load_saved_proxy_config(app_handle: &AppHandle) -> Result // On Windows, prefer the API key from Credential Manager; fall back to any // plaintext value still in the JSON if it's unavailable. #[cfg(target_os = "windows")] - if let Some(key) = load_api_key()? { + if let Some(key) = load_api_key(app_handle)? { if !key.is_empty() { config.api_key = key; } @@ -377,8 +898,43 @@ async fn load_saved_proxy_config(app_handle: &AppHandle) -> Result Ok(config) } +pub async fn ensure_proxy_running( + app_handle: AppHandle, + state: State<'_, ProxyState>, +) -> Result { + let _lifecycle_guard = state.lifecycle.lock().await; + let current = state.status().await; + if current.running { + log::info!( + "Maple proxy already running on {}:{}", + current.config.host, + current.config.port + ); + return Ok(current); + } + + let config = load_saved_proxy_config(&app_handle) + .await + .map_err(|e| format!("Failed to load proxy config: {e}"))?; + + if config.api_key.trim().is_empty() { + log::warn!("Maple proxy cannot auto-start because saved config has no API key"); + return Err("Maple proxy is not configured with an API key yet".to_string()); + } + + log::info!( + "Starting Maple proxy from saved config on {}:{}", + config.host, + config.port + ); + start_proxy_inner(app_handle, &state, config).await +} + // Initialize proxy on app startup if auto_start is enabled pub async fn init_proxy_on_startup_simple(app_handle: AppHandle) -> Result<()> { + let proxy_state: tauri::State = app_handle.state(); + let _lifecycle_guard = proxy_state.lifecycle.lock().await; + // Load saved config let config = load_saved_proxy_config(&app_handle).await?; @@ -386,11 +942,8 @@ pub async fn init_proxy_on_startup_simple(app_handle: AppHandle) -> Result<()> { if config.auto_start && !config.api_key.is_empty() { log::info!("Auto-starting proxy from saved config"); - // Get the proxy state from the app handle - let proxy_state: tauri::State = app_handle.state(); - // Try to start the proxy - match start_proxy(app_handle.clone(), proxy_state, config.clone()).await { + match start_proxy_inner(app_handle.clone(), &proxy_state, config.clone()).await { Ok(_) => { log::info!( "Proxy auto-started successfully on {}:{}", diff --git a/frontend/src/billing/billingService.ts b/frontend/src/billing/billingService.ts index 2e5b1c40..8acf3f27 100644 --- a/frontend/src/billing/billingService.ts +++ b/frontend/src/billing/billingService.ts @@ -50,6 +50,35 @@ import type { const TOKEN_STORAGE_KEY = "maple_billing_token"; +const KNOWN_MAPLE_PRODUCT_NAMES: Record = { + prod_RXODZOqZXxz5Ez: "Starter", + prod_RXODQdCZX8GtWh: "Pro", + prod_SgXHQFS10kc5hL: "Max" +}; + +function normalizeBillingStatus(status: BillingStatus): BillingStatus { + if (status.product_name?.trim()) { + return status; + } + + if (!status.is_subscribed) { + return { + ...status, + product_name: "Free" + }; + } + + const fallbackProductName = KNOWN_MAPLE_PRODUCT_NAMES[status.product_id]; + if (!fallbackProductName) { + return status; + } + + return { + ...status, + product_name: fallbackProductName + }; +} + class BillingService { private os: OpenSecretContextType; @@ -57,6 +86,10 @@ class BillingService { this.os = os; } + updateOpenSecret(os: OpenSecretContextType): void { + this.os = os; + } + private async getStoredToken(): Promise { return sessionStorage.getItem(TOKEN_STORAGE_KEY); } @@ -98,7 +131,9 @@ class BillingService { } async getBillingStatus(): Promise { - return this.executeWithToken((token) => fetchBillingStatus(token)); + return this.executeWithToken(async (token) => + normalizeBillingStatus(await fetchBillingStatus(token)) + ); } async getPortalUrl(): Promise { @@ -218,6 +253,8 @@ let billingServiceInstance: BillingService | null = null; export function initBillingService(os: OpenSecretContextType): BillingService { if (!billingServiceInstance) { billingServiceInstance = new BillingService(os); + } else { + billingServiceInstance.updateOpenSecret(os); } return billingServiceInstance; } diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 338ff672..8fe2a03b 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -54,31 +54,50 @@ import { ApiKeyManagementDialog } from "@/components/apikeys/ApiKeyManagementDia import { getTeamSeatMismatch } from "@/utils/teamSeats"; import packageJson from "../../package.json"; import { SIDEBAR_ACCOUNT_MENU_WIDTH_CLASS, SIDEBAR_LAYOUT_STYLE } from "@/constants/layout"; +import { clearAgentHistoryForUser, stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; -function ConfirmDeleteDialog() { +function ConfirmDeleteDialog({ onDeleted }: { onDeleted: () => void }) { const os = useOpenSecret(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [deleteError, setDeleteError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); async function handleDeleteHistory() { + setDeleteError(null); + setIsDeleting(true); + let operationBlock: Awaited> | null = null; try { const conversations = await os.listConversations({ limit: 1 }); if (conversations.data && conversations.data.length > 0) { await os.deleteConversations(); console.log("Server conversations deleted"); } + + operationBlock = await clearAgentHistoryForUser(os.auth.user?.user.id); + + // Refresh UI only after both hosted and local Agent Mode history are gone. + queryClient.invalidateQueries({ queryKey: ["conversations"] }); + queryClient.invalidateQueries({ queryKey: ["pinnedConversations"] }); + queryClient.invalidateQueries({ queryKey: ["projectConversations"] }); + queryClient.invalidateQueries({ queryKey: ["conversationProjects"] }); + queryClient.invalidateQueries({ queryKey: ["conversationProject"] }); + onDeleted(); + try { + await navigate({ to: "/" }); + } catch (navigationError) { + console.error("Chat history was deleted, but navigation failed:", navigationError); + window.location.href = "/"; + return; + } + window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); } catch (e) { - console.error("Error deleting conversations:", e); + console.error("Error deleting chat history:", e); + setDeleteError("Maple couldn't delete all chat history. Please try again."); + } finally { + operationBlock?.release(); + setIsDeleting(false); } - - // Always refresh UI and navigate home - queryClient.invalidateQueries({ queryKey: ["conversations"] }); - queryClient.invalidateQueries({ queryKey: ["pinnedConversations"] }); - queryClient.invalidateQueries({ queryKey: ["projectConversations"] }); - queryClient.invalidateQueries({ queryKey: ["conversationProjects"] }); - queryClient.invalidateQueries({ queryKey: ["conversationProject"] }); - navigate({ to: "/" }); - window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); } return ( @@ -87,9 +106,23 @@ function ConfirmDeleteDialog() { Are you sure? This will delete your entire chat history. + {deleteError ? ( + + + {deleteError} + + ) : null} - Cancel - Delete + Cancel + { + event.preventDefault(); + void handleDeleteHistory(); + }} + > + {isDeleting ? "Deleting..." : "Delete"} + ); @@ -99,10 +132,12 @@ export function AccountMenu() { const os = useOpenSecret(); const queryClient = useQueryClient(); const router = useRouter(); - const { billingStatus } = useLocalState(); + const { billingStatus, setBillingStatus } = useLocalState(); const [isPortalLoading, setIsPortalLoading] = useState(false); const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false); const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false); + const [isDeleteHistoryOpen, setIsDeleteHistoryOpen] = useState(false); + const [isSigningOut, setIsSigningOut] = useState(false); const [showAboutMenu, setShowAboutMenu] = useState(false); const [portalError, setPortalError] = useState(null); @@ -115,6 +150,19 @@ export function AccountMenu() { const showUpgrade = !isMax && !isTeamPlan; const showManage = (isPro || isMax || isStarter || isTeamPlan) && hasStripeAccount; + // Keep the shared sidebar billing badge current on every authenticated route, + // including Agent Mode. Some routes do not own a route-level billing refresh. + useQuery({ + queryKey: ["billingStatus"], + queryFn: async () => { + const billingService = getBillingService(); + const status = await billingService.getBillingStatus(); + setBillingStatus(status); + return status; + }, + enabled: !!os.auth.user + }); + // Fetch team status if user has team plan const { data: teamStatus } = useQuery({ queryKey: ["teamStatus"], @@ -249,7 +297,26 @@ export function AccountMenu() { }; async function signOut() { + setPortalError(null); + setIsSigningOut(true); + let operationBlock: Awaited> | null = null; + let signedOut = false; + + // Never sign out while this account may still have tools executing. try { + operationBlock = await stopAgentRuntimeForUser(os.auth.user?.user.id); + } catch (error) { + console.error("Error stopping Agent Mode:", error); + setPortalError("Maple couldn't stop Agent Mode. Please try logging out again."); + setIsSigningOut(false); + return; + } + + try { + // Credential reset is a required part of logout. + const { proxyService } = await import("@/services/proxyService"); + await proxyService.stopAndResetProxy(os.auth.user?.user.id, os.deleteApiKey); + // Try to clear billing token first try { getBillingService().clearToken(); @@ -259,16 +326,9 @@ export function AccountMenu() { sessionStorage.removeItem("maple_billing_token"); } - // Stop proxy and reset config so it doesn't auto-start on next launch - try { - const { proxyService } = await import("@/services/proxyService"); - await proxyService.stopAndResetProxy(); - } catch (error) { - console.error("Error clearing proxy config:", error); - } - // Sign out from OpenSecret await os.signOut(); + signedOut = true; queryClient.clear(); // Navigate after everything is done @@ -276,14 +336,26 @@ export function AccountMenu() { await router.navigate({ to: "/" }); } catch (error) { console.error("Error during sign out:", error); - // Force reload as last resort - window.location.href = "/"; + if (signedOut) { + window.location.href = "/"; + return; + } + setPortalError( + "Maple couldn't securely reset Agent Mode or finish logging out. Please try again." + ); + } finally { + if (!signedOut) { + operationBlock.release(); + setIsSigningOut(false); + } else { + operationBlock.retainUntilNextSession(); + } } } return (
- + !open && setShowAboutMenu(false)}>
@@ -407,9 +479,9 @@ export function AccountMenu() { - + - Log out + {isSigningOut ? "Logging out..." : "Log out"}
@@ -482,7 +554,7 @@ export function AccountMenu() {
- + setIsDeleteHistoryOpen(false)} /> {portalError && ( diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx new file mode 100644 index 00000000..d63903d2 --- /dev/null +++ b/frontend/src/components/AgentMode.tsx @@ -0,0 +1,2770 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useOpenSecret } from "@opensecret/react"; +import { + AlertCircle, + ArrowUp, + Brain, + Camera, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + Circle, + FolderOpen, + Loader2, + Lock, + MessageSquarePlus, + MoreHorizontal, + ShieldCheck, + Terminal, + Trash, + X, + Zap +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Markdown } from "@/components/markdown"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { Sidebar, SidebarToggle } from "@/components/Sidebar"; +import { MapleWordmark } from "@/components/MapleWordmark"; +import { DeleteChatDialog } from "@/components/DeleteChatDialog"; +import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; +import { + agentRuntimeService, + awaitAgentAuthUser, + type AgentConfig, + type AgentEventEnvelope, + type AgentPermissionDecision, + type AgentRuntimeStatus, + type AgentSessionSummary, + type AgentTimelineItem, + type RecentProjectRoot +} from "@/services/agentRuntimeService"; +import { + AgentProxyManualConfigConflictError, + AgentProxyReplacementSetupError, + proxyService +} from "@/services/proxyService"; +import { agentOperationFence } from "@/services/agentOperationFence"; +import { coalesceAdjacentThinkingItems, hasRenderableThinkingText } from "@/services/agentTimeline"; +import { + DEFAULT_AGENT_MODEL, + PRIMARY_AGENT_MODEL_IDS, + reconcileAgentModel +} from "@/services/agentModels"; +import { SIDEBAR_GRID_COLUMNS_CLASS, getSidebarLayoutStyle } from "@/constants/layout"; +import { + cn, + POWERFUL_MODEL_ALIAS, + QUICK_MODEL_ALIAS, + useIsLandscapeMobile, + useIsMobile +} from "@/utils/utils"; +import { isTauriDesktop } from "@/utils/platform"; +import { useLocalState } from "@/state/useLocalState"; +import type { + ModelAccessTier, + OpenSecretModel, + OpenSecretModelAlias, + OpenSecretModelCatalog +} from "@/state/LocalStateContextDef"; + +const DEFAULT_MODEL = DEFAULT_AGENT_MODEL; +const DEFAULT_MODE = "smart_approve"; +const NEW_SESSION_PENDING_KEY = "__maple-agent-new-session__"; +const MAX_STABLE_SESSION_LOAD_ATTEMPTS = 3; +const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 100; +const SIDEBAR_REORDER_ANIMATION_MS = 150; +const SIDEBAR_ICON_STROKE = 2; +const AGENT_SIDEBAR_ELLIPSIS_FADE = + "pointer-events-none w-4 shrink-0 self-stretch bg-gradient-to-r from-transparent to-[hsl(var(--muted))] dark:to-[hsl(var(--sidebar))]"; +const AGENT_SIDEBAR_ELLIPSIS_TRIGGER_ROW_BASE = + "absolute inset-y-0 right-0 z-30 flex min-h-0 items-stretch"; +const AGENT_SIDEBAR_ELLIPSIS_BUTTON = + "relative z-10 shrink-0 rounded-full border-0 bg-muted p-1.5 text-foreground/40 transition-colors dark:bg-[hsl(var(--sidebar))] hover:text-foreground group-hover:text-foreground focus-visible:text-foreground focus-visible:outline-none"; + +class PendingAgentSendCancelledError extends Error { + constructor() { + super("Agent message cancelled before the run started"); + this.name = "PendingAgentSendCancelledError"; + } +} + +function agentSidebarEllipsisTriggerRowClass(isCompactLayout: boolean): string { + if (isCompactLayout) return AGENT_SIDEBAR_ELLIPSIS_TRIGGER_ROW_BASE; + return `${AGENT_SIDEBAR_ELLIPSIS_TRIGGER_ROW_BASE} transition-opacity duration-150 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 has-[[data-state=open]]:pointer-events-auto has-[[data-state=open]]:opacity-100`; +} + +type AgentPermissionMode = "smart_approve" | "auto"; + +const AGENT_PERMISSION_MODES: Array<{ + value: AgentPermissionMode; + label: string; + description: string; +}> = [ + { + value: "smart_approve", + label: "Read only", + description: "Auto-runs read-only tools; asks before writes" + }, + { + value: "auto", + label: "Allow all", + description: "Allows all tool calls without prompting" + } +]; + +const QUICK_AGENT_MODEL = { + id: QUICK_MODEL_ALIAS, + label: "Quick", + icon: Zap, + description: "Fast, everyday responses", + access: "free" as ModelAccessTier, + capabilities: { vision: false, reasoning: true } +} as const; + +const LEGACY_POWERFUL_AGENT_ALIAS = { + id: POWERFUL_MODEL_ALIAS, + label: "Powerful", + icon: Brain, + description: "Deeper thinking & analysis", + access: "pro" as ModelAccessTier, + capabilities: { vision: true, reasoning: true } +} as const; + +const PRIMARY_AGENT_MODELS = PRIMARY_AGENT_MODEL_IDS.map((id) => + id === DEFAULT_AGENT_MODEL + ? { + id: DEFAULT_AGENT_MODEL, + label: "GLM 5.2", + icon: Brain, + description: "Recommended for Agent Mode", + access: "pro" as ModelAccessTier, + capabilities: { vision: false, reasoning: true } + } + : QUICK_AGENT_MODEL +); + +const FALLBACK_AGENT_MODEL_ALIASES = [QUICK_AGENT_MODEL, LEGACY_POWERFUL_AGENT_ALIAS] as const; + +const FALLBACK_ALIAS_TARGETS = { + [QUICK_MODEL_ALIAS]: "gpt-oss-120b", + [POWERFUL_MODEL_ALIAS]: "kimi-k2-6" +} as const; + +type ModelCatalogClient = { + fetchModelCatalog?: () => Promise; + fetchModels?: () => Promise; +}; + +function normalizeAgentPermissionMode(mode?: string | null): AgentPermissionMode { + return mode === "auto" ? "auto" : DEFAULT_MODE; +} + +function isSelectableChatModel(model: OpenSecretModel): boolean { + return model.enabled !== false && model.deprecated !== true && model.capabilities?.chat !== false; +} + +function buildFallbackModelAliases(models: OpenSecretModel[]): OpenSecretModelAlias[] { + const modelById = new Map(models.map((availableModel) => [availableModel.id, availableModel])); + + return FALLBACK_AGENT_MODEL_ALIASES.map((primaryModel) => { + const targetModel = modelById.get(FALLBACK_ALIAS_TARGETS[primaryModel.id]); + + return { + id: primaryModel.id, + label: primaryModel.label, + short_name: primaryModel.label, + description: primaryModel.description, + target_model: targetModel?.id || "", + access: targetModel?.access || primaryModel.access, + capabilities: targetModel?.capabilities || primaryModel.capabilities + }; + }); +} + +export function AgentMode({ userId }: { userId: string }) { + const os = useOpenSecret(); + const { createApiKey, deleteApiKey } = os; + const isMobile = useIsMobile(); + const isLandscapeMobile = useIsLandscapeMobile(); + const isCompactLayout = isMobile || isLandscapeMobile; + const [isSidebarOpen, setIsSidebarOpen] = useState(!isCompactLayout); + const [runtimeStatus, setRuntimeStatus] = useState(null); + const [recentRoots, setRecentRoots] = useState([]); + const [sessions, setSessions] = useState([]); + const [sessionToDelete, setSessionToDelete] = useState(null); + const [activeSessionId, setActiveSessionId] = useState(null); + const [projectRoot, setProjectRoot] = useState(""); + const [model, setModel] = useState(DEFAULT_MODEL); + const [mode, setMode] = useState(DEFAULT_MODE); + const [timelineItems, setTimelineItems] = useState([]); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [hasManualProxyConflict, setHasManualProxyConflict] = useState(false); + const [isAuthTransitionReady, setIsAuthTransitionReady] = useState(false); + const [isInitializing, setIsInitializing] = useState(true); + const [isReplacingManualProxy, setIsReplacingManualProxy] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [pendingSendSessionIds, setPendingSendSessionIds] = useState>(() => new Set()); + const [pendingSessionSelectionId, setPendingSessionSelectionId] = useState(null); + const [activeRunsBySession, setActiveRunsBySession] = useState>({}); + const [completedUnreadSessionIds, setCompletedUnreadSessionIds] = useState>( + () => new Set() + ); + const chatContainerRef = useRef(null); + const activeSessionIdRef = useRef(activeSessionId); + const deletedSessionIdsRef = useRef(new Set()); + const shouldAutoScrollRef = useRef(true); + const projectRootPersistenceRef = useRef>(Promise.resolve()); + const terminalRunIdsRef = useRef(new Set()); + const pendingSendTokensRef = useRef(new Map()); + const cancelledPendingSendTokensRef = useRef(new Set()); + const nextSendTokenRef = useRef(0); + const activeRunsBySessionRef = useRef>({}); + const timelineRevisionBySessionRef = useRef(new Map()); + const sessionSelectionGenerationRef = useRef(0); + const pendingSessionSelectionIdRef = useRef(null); + const interactionGenerationRef = useRef(0); + const startRequestGenerationRef = useRef(0); + const runStateGenerationRef = useRef(0); + + useEffect(() => { + if (isCompactLayout) { + setIsSidebarOpen(false); + } + }, [isCompactLayout]); + + useEffect(() => { + activeSessionIdRef.current = activeSessionId; + }, [activeSessionId]); + + const updateAutoScrollFromPosition = useCallback(() => { + const container = chatContainerRef.current; + if (!container) return; + + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + shouldAutoScrollRef.current = distanceFromBottom < AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + }, []); + + const scrollTimelineToBottom = useCallback((behavior: ScrollBehavior = "auto") => { + const container = chatContainerRef.current; + if (!container) return; + + container.scrollTo({ + top: container.scrollHeight, + behavior + }); + }, []); + + useEffect(() => { + if (!shouldAutoScrollRef.current) return; + + const frame = requestAnimationFrame(() => { + if (shouldAutoScrollRef.current) { + scrollTimelineToBottom("auto"); + } + }); + + return () => cancelAnimationFrame(frame); + }, [scrollTimelineToBottom, timelineItems]); + + const activeRootLabel = useMemo(() => { + if (!projectRoot) return "Select folder"; + return recentRoots.find((root) => root.path === projectRoot)?.name || basename(projectRoot); + }, [projectRoot, recentRoots]); + const activeRunId = activeSessionId ? (activeRunsBySession[activeSessionId] ?? null) : null; + const activePendingSendKey = activeSessionId || NEW_SESSION_PENDING_KEY; + const isSubmitting = pendingSendSessionIds.has(activePendingSendKey); + const isSessionSelectionPending = pendingSessionSelectionId !== null; + const isCreatingSessionForSend = pendingSendSessionIds.has(NEW_SESSION_PENDING_KEY); + const areAgentSettingsLocked = + !isAuthTransitionReady || + isInitializing || + isStarting || + isSessionSelectionPending || + isCreatingSessionForSend || + isReplacingManualProxy || + hasManualProxyConflict; + const isAgentSendLocked = areAgentSettingsLocked; + const isSending = Boolean(activeRunId) || isSubmitting; + const runningSessionIds = useMemo(() => { + const ids = new Set(Object.keys(activeRunsBySession)); + for (const sessionId of pendingSendSessionIds) { + if (sessionId !== NEW_SESSION_PENDING_KEY) ids.add(sessionId); + } + if (pendingSessionSelectionId && pendingSessionSelectionId !== NEW_SESSION_PENDING_KEY) { + ids.add(pendingSessionSelectionId); + } + return ids; + }, [activeRunsBySession, pendingSendSessionIds, pendingSessionSelectionId]); + + const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), []); + + const beginSessionSelection = useCallback((sessionId: string): number => { + interactionGenerationRef.current += 1; + const generation = sessionSelectionGenerationRef.current + 1; + sessionSelectionGenerationRef.current = generation; + pendingSessionSelectionIdRef.current = sessionId; + setPendingSessionSelectionId(sessionId); + return generation; + }, []); + + const finishSessionSelection = useCallback((generation: number): boolean => { + if (sessionSelectionGenerationRef.current !== generation) return false; + pendingSessionSelectionIdRef.current = null; + setPendingSessionSelectionId(null); + return true; + }, []); + + const invalidateSessionSelection = useCallback(() => { + interactionGenerationRef.current += 1; + sessionSelectionGenerationRef.current += 1; + pendingSessionSelectionIdRef.current = null; + setPendingSessionSelectionId(null); + }, []); + + const markPendingSend = useCallback((sessionKey: string, token: number) => { + pendingSendTokensRef.current.set(sessionKey, token); + setPendingSendSessionIds((current) => { + if (current.has(sessionKey)) return current; + const next = new Set(current); + next.add(sessionKey); + return next; + }); + }, []); + + const movePendingSend = useCallback((fromKey: string, toKey: string, token: number) => { + if (fromKey === toKey || pendingSendTokensRef.current.get(fromKey) !== token) return; + pendingSendTokensRef.current.delete(fromKey); + pendingSendTokensRef.current.set(toKey, token); + setPendingSendSessionIds((current) => { + const next = new Set(current); + next.delete(fromKey); + next.add(toKey); + return next; + }); + }, []); + + const clearPendingSend = useCallback((sessionKey: string, token?: number) => { + if (token !== undefined && pendingSendTokensRef.current.get(sessionKey) !== token) return; + if (!pendingSendTokensRef.current.delete(sessionKey)) return; + setPendingSendSessionIds((current) => { + if (!current.has(sessionKey)) return current; + const next = new Set(current); + next.delete(sessionKey); + return next; + }); + }, []); + + const applyRuntimeStatus = useCallback( + (status: AgentRuntimeStatus, expectedRunStateGeneration?: number) => { + if ( + expectedRunStateGeneration !== undefined && + runStateGenerationRef.current !== expectedRunStateGeneration + ) { + return; + } + setRuntimeStatus(status); + const activeRuns = status.activeRuns || {}; + activeRunsBySessionRef.current = activeRuns; + setActiveRunsBySession(activeRuns); + }, + [] + ); + + const recordActiveRun = useCallback((sessionId: string, runId: string) => { + const next = { ...activeRunsBySessionRef.current, [sessionId]: runId }; + activeRunsBySessionRef.current = next; + setActiveRunsBySession(next); + }, []); + + const clearActiveRun = useCallback((sessionId: string, expectedRunId?: string) => { + const current = activeRunsBySessionRef.current; + if (expectedRunId && current[sessionId] !== expectedRunId) return; + if (!(sessionId in current)) return; + const next = { ...current }; + delete next[sessionId]; + activeRunsBySessionRef.current = next; + setActiveRunsBySession(next); + }, []); + + const bumpTimelineRevision = useCallback((sessionId: string): number => { + const revision = (timelineRevisionBySessionRef.current.get(sessionId) || 0) + 1; + timelineRevisionBySessionRef.current.set(sessionId, revision); + return revision; + }, []); + + const replaceSessionTimeline = useCallback( + (sessionId: string, items: AgentTimelineItem[], expectedRevision?: number): boolean => { + if ( + expectedRevision !== undefined && + (timelineRevisionBySessionRef.current.get(sessionId) || 0) !== expectedRevision + ) { + return false; + } + bumpTimelineRevision(sessionId); + if (activeSessionIdRef.current === sessionId) { + setTimelineItems(items); + } + return true; + }, + [bumpTimelineRevision] + ); + + const mergeSessionTimelineItem = useCallback( + (sessionId: string, item: AgentTimelineItem) => { + bumpTimelineRevision(sessionId); + if (activeSessionIdRef.current === sessionId) { + setTimelineItems((current) => mergeTimelineItem(current, item)); + } + }, + [bumpTimelineRevision] + ); + + const clearCompletedUnreadSession = useCallback((sessionId: string) => { + setCompletedUnreadSessionIds((current) => { + if (!current.has(sessionId)) return current; + const next = new Set(current); + next.delete(sessionId); + return next; + }); + }, []); + + const markCompletedUnreadSession = useCallback((sessionId: string) => { + setCompletedUnreadSessionIds((current) => { + if (current.has(sessionId)) return current; + const next = new Set(current); + next.add(sessionId); + return next; + }); + }, []); + + const trackAgentWorkflow = useCallback( + async (workflow: () => Promise): Promise => { + return await agentOperationFence.run(userId, workflow); + }, + [userId] + ); + + const ensureMapleProxyReady = useCallback(async () => { + try { + const status = await trackAgentWorkflow(async () => { + return await proxyService.ensureProxyReady( + userId, + async (name) => { + const response = await createApiKey(name); + return response.key; + }, + async (name) => { + await deleteApiKey(name); + } + ); + }); + setHasManualProxyConflict(false); + return status; + } catch (proxyError) { + if (proxyError instanceof AgentProxyManualConfigConflictError) { + setHasManualProxyConflict(true); + } + throw proxyError; + } + }, [createApiKey, deleteApiKey, trackAgentWorkflow, userId]); + + const persistProjectRoot = useCallback( + async (path: string): Promise => { + const previousOperation = projectRootPersistenceRef.current; + const operation = trackAgentWorkflow(async () => { + await previousOperation; + const [config, roots] = await Promise.all([ + agentRuntimeService.loadConfig(userId), + agentRuntimeService.saveRecentProjectRoot(userId, path) + ]); + const nextConfig: AgentConfig = { + ...config, + defaultProjectRoot: path + }; + await agentRuntimeService.saveConfig(userId, nextConfig); + return roots; + }); + projectRootPersistenceRef.current = operation.then( + () => undefined, + () => undefined + ); + return await operation; + }, + [trackAgentWorkflow, userId] + ); + + const refreshSessionList = useCallback(async () => { + return await trackAgentWorkflow(async () => { + if (!isTauriDesktop()) return; + const nextSessions = await agentRuntimeService.listSessions(userId, null); + setSessions(nextSessions.filter((session) => !deletedSessionIdsRef.current.has(session.id))); + }); + }, [trackAgentWorkflow, userId]); + + const refreshSessions = useCallback(async () => { + return await trackAgentWorkflow(async () => { + if (!isTauriDesktop()) return; + const runStateGeneration = runStateGenerationRef.current; + const status = await agentRuntimeService.getRuntimeStatus(userId); + applyRuntimeStatus(status, runStateGeneration); + if (!status.running) { + // Session history is account-scoped local data and does not require a + // live runtime or verified proxy credential. + await refreshSessionList(); + return; + } + await refreshSessionList(); + }); + }, [applyRuntimeStatus, refreshSessionList, trackAgentWorkflow, userId]); + + useEffect(() => { + let cancelled = false; + const initializationGeneration = interactionGenerationRef.current; + setIsInitializing(true); + async function loadInitialState() { + if (!isTauriDesktop()) return; + try { + const runStateGeneration = runStateGenerationRef.current; + const [status, config, roots] = await Promise.all([ + agentRuntimeService.getRuntimeStatus(userId), + agentRuntimeService.loadConfig(userId), + agentRuntimeService.listRecentProjectRoots(userId) + ]); + if (cancelled || interactionGenerationRef.current !== initializationGeneration) { + return; + } + + applyRuntimeStatus(status, runStateGeneration); + setRecentRoots(roots); + const root = config.defaultProjectRoot || status.projectRoot || roots[0]?.path || ""; + const nextModel = status.model || config.defaultModel || DEFAULT_MODEL; + const nextMode = normalizeAgentPermissionMode(status.mode); + setProjectRoot(root); + setModel(nextModel); + setMode(nextMode); + + // Session history is local account data and remains browseable even + // when an existing proxy credential requires explicit replacement. + await refreshSessionList(); + if (cancelled || interactionGenerationRef.current !== initializationGeneration) { + return; + } + + await ensureMapleProxyReady(); + if (cancelled || interactionGenerationRef.current !== initializationGeneration) { + return; + } + if (status.running) { + await refreshSessions(); + } else if (root) { + const startRunStateGeneration = runStateGenerationRef.current; + const startedStatus = await agentRuntimeService.startRuntime(userId, { + projectRoot: root, + model: nextModel, + mode: nextMode + }); + if (cancelled || interactionGenerationRef.current !== initializationGeneration) { + return; + } + applyRuntimeStatus(startedStatus, startRunStateGeneration); + await refreshSessions(); + } + } catch (loadError) { + if ( + !cancelled && + interactionGenerationRef.current === initializationGeneration && + !(loadError instanceof AgentProxyManualConfigConflictError) + ) { + setError(errorMessage(loadError)); + } + } + } + void awaitAgentAuthUser(userId) + .then(async () => { + if (cancelled) return; + setIsAuthTransitionReady(true); + await trackAgentWorkflow(loadInitialState); + }) + .catch((loadError) => { + if ( + !cancelled && + interactionGenerationRef.current === initializationGeneration && + !(loadError instanceof AgentProxyManualConfigConflictError) + ) { + setError(errorMessage(loadError)); + } + }) + .finally(() => { + if (!cancelled) setIsInitializing(false); + }); + return () => { + cancelled = true; + }; + }, [ + applyRuntimeStatus, + ensureMapleProxyReady, + refreshSessionList, + refreshSessions, + trackAgentWorkflow, + userId + ]); + + const chooseProjectRoot = useCallback(async () => { + if (!isTauriDesktop()) return; + try { + await trackAgentWorkflow(async () => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + directory: true, + multiple: false, + title: "Select project folder" + }); + if (typeof selected === "string") { + invalidateSessionSelection(); + const interactionGeneration = interactionGenerationRef.current; + shouldAutoScrollRef.current = true; + setProjectRoot(selected); + activeSessionIdRef.current = null; + setActiveSessionId(null); + setTimelineItems([]); + const roots = await persistProjectRoot(selected); + if (interactionGenerationRef.current === interactionGeneration) { + setRecentRoots(roots); + } + } + }); + } catch (chooseError) { + setError(errorMessage(chooseError)); + } + }, [invalidateSessionSelection, persistProjectRoot, trackAgentWorkflow]); + + const selectProjectRoot = useCallback( + (value: string) => { + invalidateSessionSelection(); + const interactionGeneration = interactionGenerationRef.current; + setProjectRoot(value); + setActiveSessionId(null); + activeSessionIdRef.current = null; + setTimelineItems([]); + shouldAutoScrollRef.current = true; + void (async () => { + try { + const roots = await persistProjectRoot(value); + if (interactionGenerationRef.current === interactionGeneration) { + setRecentRoots(roots); + await refreshSessions(); + } + } catch (selectError) { + if (interactionGenerationRef.current === interactionGeneration) { + setError(errorMessage(selectError)); + } + } + })(); + }, + [invalidateSessionSelection, persistProjectRoot, refreshSessions] + ); + + const selectModel = useCallback((value: string) => { + interactionGenerationRef.current += 1; + setModel(value); + }, []); + + const selectMode = useCallback((value: AgentPermissionMode) => { + interactionGenerationRef.current += 1; + setMode(value); + }, []); + + const startRuntime = useCallback( + async (restart = false) => { + const requestGeneration = startRequestGenerationRef.current + 1; + startRequestGenerationRef.current = requestGeneration; + const interactionGeneration = interactionGenerationRef.current; + setError(null); + setIsStarting(true); + try { + return await trackAgentWorkflow(async () => { + if (!projectRoot) { + throw new Error("Select a project folder first"); + } + await ensureMapleProxyReady(); + const request = { projectRoot, model: model || DEFAULT_MODEL, mode }; + const runStateGeneration = runStateGenerationRef.current; + const status = restart + ? await agentRuntimeService.restartRuntime(userId, request) + : await agentRuntimeService.startRuntime(userId, request); + const roots = await agentRuntimeService.listRecentProjectRoots(userId); + if ( + startRequestGenerationRef.current !== requestGeneration || + interactionGenerationRef.current !== interactionGeneration + ) { + return status; + } + applyRuntimeStatus(status, runStateGeneration); + setProjectRoot(status.projectRoot || projectRoot); + setModel(status.model || model || DEFAULT_MODEL); + setMode(normalizeAgentPermissionMode(status.mode || mode)); + setRecentRoots(roots); + await refreshSessions(); + return status; + }); + } catch (startError) { + if ( + startRequestGenerationRef.current === requestGeneration && + interactionGenerationRef.current === interactionGeneration && + !(startError instanceof AgentProxyManualConfigConflictError) + ) { + setError(errorMessage(startError)); + } + throw startError; + } finally { + if (startRequestGenerationRef.current === requestGeneration) { + setIsStarting(false); + } + } + }, + [ + applyRuntimeStatus, + ensureMapleProxyReady, + mode, + model, + projectRoot, + refreshSessions, + trackAgentWorkflow, + userId + ] + ); + + const replaceManualProxyForAgent = useCallback(async () => { + interactionGenerationRef.current += 1; + setError(null); + setIsReplacingManualProxy(true); + try { + await trackAgentWorkflow(async () => { + await proxyService.replaceOwnerlessProxyAndEnsureReady( + userId, + async (name) => { + const response = await createApiKey(name); + return response.key; + }, + async (name) => { + await deleteApiKey(name); + } + ); + }); + setHasManualProxyConflict(false); + if (projectRoot) { + await startRuntime(Boolean(runtimeStatus?.running)); + } + } catch (replaceError) { + if (replaceError instanceof AgentProxyManualConfigConflictError) { + setHasManualProxyConflict(true); + } else if (replaceError instanceof AgentProxyReplacementSetupError) { + setHasManualProxyConflict(false); + setError(replaceError.message); + } else { + setError(errorMessage(replaceError)); + } + } finally { + setIsReplacingManualProxy(false); + } + }, [ + createApiKey, + deleteApiKey, + projectRoot, + runtimeStatus?.running, + startRuntime, + trackAgentWorkflow, + userId + ]); + + const ensureRuntimeAndSession = useCallback( + async ( + expectedSelectionGeneration: number, + expectedInteractionGeneration: number, + requestedSessionId: string | null + ) => { + if (!projectRoot) { + throw new Error("Select a project folder first"); + } + + const status = await agentRuntimeService.getRuntimeStatus(userId); + if (!status.running) { + await startRuntime(false); + } + + let sessionId = requestedSessionId; + if (!sessionId) { + const detail = await agentRuntimeService.createSession(userId, { + projectRoot, + title: "New agent session", + model: model || DEFAULT_MODEL, + mode + }); + // Goose may reuse the newest deleted session ID. This detail represents + // a new persisted session, so it supersedes any local deletion tombstone. + deletedSessionIdsRef.current.delete(detail.session.id); + sessionId = detail.session.id; + setSessions((current) => [ + detail.session, + ...current.filter((item) => item.id !== detail.session.id) + ]); + replaceSessionTimeline(sessionId, detail.timeline); + + // A send that creates a session may finish after the user selects a + // different chat. Keep the new chat/run, but never steal focus back. + if ( + sessionSelectionGenerationRef.current === expectedSelectionGeneration && + interactionGenerationRef.current === expectedInteractionGeneration && + activeSessionIdRef.current === null + ) { + shouldAutoScrollRef.current = true; + activeSessionIdRef.current = sessionId; + setActiveSessionId(sessionId); + setMode(normalizeAgentPermissionMode(detail.session.mode)); + replaceSessionTimeline(sessionId, detail.timeline); + } + } + + return sessionId; + }, + [mode, model, projectRoot, replaceSessionTimeline, startRuntime, userId] + ); + + const createSession = useCallback(async () => { + if (pendingSessionSelectionIdRef.current === NEW_SESSION_PENDING_KEY) return; + const selectionGeneration = beginSessionSelection(NEW_SESSION_PENDING_KEY); + const interactionGeneration = interactionGenerationRef.current; + setError(null); + try { + const detail = await trackAgentWorkflow(async () => { + if (!runtimeStatus?.running) { + await startRuntime(false); + } + return await agentRuntimeService.createSession(userId, { + projectRoot, + title: "New agent session", + model: model || DEFAULT_MODEL, + mode + }); + }); + deletedSessionIdsRef.current.delete(detail.session.id); + setSessions((current) => [ + detail.session, + ...current.filter((session) => session.id !== detail.session.id) + ]); + replaceSessionTimeline(detail.session.id, detail.timeline); + + if ( + sessionSelectionGenerationRef.current === selectionGeneration && + interactionGenerationRef.current === interactionGeneration + ) { + shouldAutoScrollRef.current = true; + activeSessionIdRef.current = detail.session.id; + setActiveSessionId(detail.session.id); + setMode(normalizeAgentPermissionMode(detail.session.mode)); + replaceSessionTimeline(detail.session.id, detail.timeline); + } + } catch (createError) { + if ( + sessionSelectionGenerationRef.current === selectionGeneration && + interactionGenerationRef.current === interactionGeneration + ) { + setError(errorMessage(createError)); + } + } finally { + finishSessionSelection(selectionGeneration); + } + }, [ + beginSessionSelection, + finishSessionSelection, + mode, + model, + projectRoot, + replaceSessionTimeline, + runtimeStatus?.running, + startRuntime, + trackAgentWorkflow, + userId + ]); + + const loadSession = useCallback( + async (sessionId: string) => { + const selectionGeneration = beginSessionSelection(sessionId); + const interactionGeneration = interactionGenerationRef.current; + setError(null); + clearCompletedUnreadSession(sessionId); + try { + const loaded = await trackAgentWorkflow(async () => { + for (let attempt = 0; attempt < MAX_STABLE_SESSION_LOAD_ATTEMPTS; attempt += 1) { + const timelineRevision = timelineRevisionBySessionRef.current.get(sessionId) || 0; + const detail = await agentRuntimeService.loadSession(userId, sessionId); + if ((timelineRevisionBySessionRef.current.get(sessionId) || 0) === timelineRevision) { + return { detail, timelineRevision }; + } + } + throw new Error("This Agent session is still updating. Try selecting it again shortly."); + }); + const { detail, timelineRevision } = loaded; + if ( + sessionSelectionGenerationRef.current !== selectionGeneration || + interactionGenerationRef.current !== interactionGeneration || + deletedSessionIdsRef.current.has(sessionId) + ) { + return; + } + + // Validate and install the snapshot before switching focus. A live + // event can arrive between the native read and this continuation; in + // that case leave the previous chat intact instead of overwriting the + // newer timeline with a stale snapshot. + if (!replaceSessionTimeline(detail.session.id, detail.timeline, timelineRevision)) { + throw new Error("This Agent session changed while loading. Try selecting it again."); + } + + // Commit the selected session and all of its settings together. Until + // this point the previous chat remains active and its composer is gated. + shouldAutoScrollRef.current = true; + activeSessionIdRef.current = detail.session.id; + setActiveSessionId(detail.session.id); + setProjectRoot(detail.session.projectRoot); + if (detail.session.model) { + setModel(detail.session.model); + } + setMode(normalizeAgentPermissionMode(detail.session.mode)); + setTimelineItems(detail.timeline); + finishSessionSelection(selectionGeneration); + + try { + const roots = await persistProjectRoot(detail.session.projectRoot); + if ( + sessionSelectionGenerationRef.current === selectionGeneration && + interactionGenerationRef.current === interactionGeneration && + activeSessionIdRef.current === detail.session.id + ) { + setRecentRoots(roots); + } + } catch (persistError) { + if ( + sessionSelectionGenerationRef.current === selectionGeneration && + interactionGenerationRef.current === interactionGeneration && + activeSessionIdRef.current === detail.session.id + ) { + setError(errorMessage(persistError)); + } + } + } catch (loadError) { + if ( + sessionSelectionGenerationRef.current === selectionGeneration && + interactionGenerationRef.current === interactionGeneration + ) { + setError(errorMessage(loadError)); + } + } finally { + finishSessionSelection(selectionGeneration); + } + }, + [ + beginSessionSelection, + clearCompletedUnreadSession, + finishSessionSelection, + persistProjectRoot, + replaceSessionTimeline, + trackAgentWorkflow, + userId + ] + ); + + const sendMessage = useCallback(async () => { + const text = input.trim(); + const requestedSessionId = activeSessionIdRef.current; + let pendingSessionKey = requestedSessionId || NEW_SESSION_PENDING_KEY; + if ( + !text || + isAgentSendLocked || + pendingSessionSelectionIdRef.current !== null || + pendingSendTokensRef.current.has(pendingSessionKey) || + (requestedSessionId && activeRunsBySession[requestedSessionId]) + ) { + return; + } + + const selectionGeneration = sessionSelectionGenerationRef.current; + const interactionGeneration = interactionGenerationRef.current; + const sendToken = nextSendTokenRef.current + 1; + nextSendTokenRef.current = sendToken; + let targetSessionId = requestedSessionId; + markPendingSend(pendingSessionKey, sendToken); + + setError(null); + setInput(""); + shouldAutoScrollRef.current = true; + requestAnimationFrame(() => scrollTimelineToBottom("smooth")); + try { + await trackAgentWorkflow(async () => { + const sessionId = await ensureRuntimeAndSession( + selectionGeneration, + interactionGeneration, + requestedSessionId + ); + targetSessionId = sessionId; + if (pendingSessionKey !== sessionId) { + movePendingSend(pendingSessionKey, sessionId, sendToken); + pendingSessionKey = sessionId; + } + if (cancelledPendingSendTokensRef.current.has(sendToken)) { + throw new PendingAgentSendCancelledError(); + } + const response = await agentRuntimeService.sendMessage(userId, { + sessionId, + text, + model: model || DEFAULT_MODEL, + mode + }); + if (cancelledPendingSendTokensRef.current.has(sendToken)) { + // The native command may have crossed the start boundary while the + // user clicked Cancel. Cancel the concrete run before returning. + await agentRuntimeService.cancelRun(userId, response.runId); + return; + } + if (!terminalRunIdsRef.current.has(response.runId)) { + recordActiveRun(sessionId, response.runId); + } + }); + } catch (sendError) { + if (sendError instanceof PendingAgentSendCancelledError) { + setInput((current) => (current ? current : text)); + return; + } + const message = errorMessage(sendError); + if ( + (targetSessionId && activeSessionIdRef.current === targetSessionId) || + (!targetSessionId && + activeSessionIdRef.current === null && + sessionSelectionGenerationRef.current === selectionGeneration && + interactionGenerationRef.current === interactionGeneration) + ) { + setError(message); + } + if (targetSessionId && !deletedSessionIdsRef.current.has(targetSessionId)) { + mergeSessionTimelineItem(targetSessionId, { + id: `error-${Date.now()}-${sendToken}`, + itemType: "error", + role: "system", + title: "Agent error", + text: message, + status: "failed", + createdMs: Date.now(), + merge: "replace" + }); + } + } finally { + cancelledPendingSendTokensRef.current.delete(sendToken); + clearPendingSend(pendingSessionKey, sendToken); + } + }, [ + activeRunsBySession, + clearPendingSend, + ensureRuntimeAndSession, + input, + isAgentSendLocked, + markPendingSend, + mergeSessionTimelineItem, + mode, + model, + movePendingSend, + recordActiveRun, + scrollTimelineToBottom, + trackAgentWorkflow, + userId + ]); + + const cancelPrompt = useCallback(async () => { + const sessionId = activeSessionIdRef.current; + const currentRunId = sessionId ? activeRunsBySessionRef.current[sessionId] : activeRunId; + if (!currentRunId) { + const pendingSessionKey = activeSessionIdRef.current || NEW_SESSION_PENDING_KEY; + const pendingSendToken = pendingSendTokensRef.current.get(pendingSessionKey); + if (pendingSendToken !== undefined) { + cancelledPendingSendTokensRef.current.add(pendingSendToken); + } + return; + } + try { + await agentRuntimeService.cancelRun(userId, currentRunId); + } catch (cancelError) { + if (activeSessionIdRef.current === sessionId) { + setError(errorMessage(cancelError)); + } + } + }, [activeRunId, userId]); + + const respondToPermission = useCallback( + async (item: AgentTimelineItem, decision: AgentPermissionDecision) => { + const sessionId = activeSessionIdRef.current; + try { + if (!sessionId) throw new Error("No active Agent session for this permission request"); + await agentRuntimeService.respondToPermission( + userId, + sessionId, + permissionRequestId(item), + decision + ); + // Rust emits the authoritative revision-aware timelineItem before this + // command returns. Replacing a render-closure snapshot here could erase + // tool output that arrived while the permission response was in flight. + } catch (permissionError) { + if (activeSessionIdRef.current === sessionId) { + setError(errorMessage(permissionError)); + } + } + }, + [userId] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey && !isCompactLayout) { + event.preventDefault(); + void sendMessage(); + } + }, + [isCompactLayout, sendMessage] + ); + + const sidebarLayoutStyle = getSidebarLayoutStyle({ offsetContent: isSidebarOpen }); + const removeSessionFromState = useCallback( + (sessionId: string) => { + deletedSessionIdsRef.current.add(sessionId); + timelineRevisionBySessionRef.current.delete(sessionId); + setSessions((current) => current.filter((session) => session.id !== sessionId)); + setCompletedUnreadSessionIds((current) => { + if (!current.has(sessionId)) return current; + const next = new Set(current); + next.delete(sessionId); + return next; + }); + clearActiveRun(sessionId); + setSessionToDelete((current) => (current?.id === sessionId ? null : current)); + + if (activeSessionIdRef.current === sessionId) { + activeSessionIdRef.current = null; + shouldAutoScrollRef.current = true; + setActiveSessionId(null); + setTimelineItems([]); + setInput(""); + } + }, + [clearActiveRun] + ); + + const deleteSession = useCallback( + async (sessionId: string) => { + setError(null); + try { + await agentRuntimeService.deleteSession(userId, sessionId); + removeSessionFromState(sessionId); + } catch (deleteError) { + setError(errorMessage(deleteError)); + } + }, + [removeSessionFromState, userId] + ); + + const upsertSessionSummary = useCallback((summary: AgentSessionSummary) => { + if (deletedSessionIdsRef.current.has(summary.id)) return; + setSessions((current) => { + let replaced = false; + const next = current.map((session) => { + if (session.id !== summary.id) return session; + replaced = true; + return summary; + }); + return replaced ? next : [summary, ...current]; + }); + }, []); + + const handleAgentEvent = useCallback( + (event: AgentEventEnvelope) => { + const eventSessionId = event.sessionId || event.session?.id; + if (eventSessionId && deletedSessionIdsRef.current.has(eventSessionId)) { + return; + } + + switch (event.eventType) { + case "runtimeStatus": + if (event.status) { + runStateGenerationRef.current += 1; + applyRuntimeStatus(event.status); + } + break; + case "sessionCreated": + if (event.session) { + upsertSessionSummary(event.session); + } + break; + case "sessionUpdated": + if (event.session) { + upsertSessionSummary(event.session); + } + break; + case "runStarted": + runStateGenerationRef.current += 1; + if (event.sessionId && event.runId && !terminalRunIdsRef.current.has(event.runId)) { + bumpTimelineRevision(event.sessionId); + clearCompletedUnreadSession(event.sessionId); + recordActiveRun(event.sessionId, event.runId); + } + break; + case "timelineItem": + if (event.item && event.sessionId) { + mergeSessionTimelineItem(event.sessionId, event.item); + } + break; + case "runFinished": { + runStateGenerationRef.current += 1; + if (event.runId) terminalRunIdsRef.current.add(event.runId); + let finishedTimelineRevision: number | undefined; + if (event.sessionId) { + finishedTimelineRevision = bumpTimelineRevision(event.sessionId); + clearActiveRun(event.sessionId, event.runId || undefined); + } + // The terminal event is authoritative for run state. Refresh only + // persisted session metadata here: the native task removes its + // active-run entry immediately after emitting this event, so a + // concurrent status snapshot could otherwise resurrect the run. + void refreshSessionList().catch(() => {}); + if (event.sessionId && (event.message === "completed" || event.message === "cancelled")) { + if (event.message === "completed" && event.sessionId !== activeSessionIdRef.current) { + markCompletedUnreadSession(event.sessionId); + } + void agentRuntimeService + .loadSession(userId, event.sessionId) + .then((detail) => { + if (!deletedSessionIdsRef.current.has(event.sessionId!)) { + replaceSessionTimeline( + event.sessionId!, + detail.timeline, + finishedTimelineRevision + ); + } + }) + .catch(() => {}); + } + break; + } + case "error": + if (event.message && !event.sessionId) { + setError(event.message); + } + if (event.item && event.sessionId) { + mergeSessionTimelineItem(event.sessionId, event.item); + } + break; + case "historyReplaced": + void (async () => { + const id = event.sessionId || activeSessionIdRef.current; + if (!id) return; + const historyTimelineRevision = bumpTimelineRevision(id); + try { + const detail = await agentRuntimeService.loadSession(userId, id); + if (!deletedSessionIdsRef.current.has(id)) { + replaceSessionTimeline(id, detail.timeline, historyTimelineRevision); + } + } catch (historyError) { + if (activeSessionIdRef.current === id) { + setError(errorMessage(historyError)); + } + } + })(); + break; + } + }, + [ + applyRuntimeStatus, + bumpTimelineRevision, + clearActiveRun, + clearCompletedUnreadSession, + markCompletedUnreadSession, + mergeSessionTimelineItem, + refreshSessionList, + recordActiveRun, + replaceSessionTimeline, + upsertSessionSummary, + userId + ] + ); + + useEffect(() => { + let unlisten: (() => void) | null = null; + let cancelled = false; + void awaitAgentAuthUser(userId) + .then(async () => { + return await agentRuntimeService.listenToEvents((event) => { + if (!cancelled) handleAgentEvent(event); + }); + }) + .then((nextUnlisten) => { + if (cancelled) { + nextUnlisten(); + } else { + unlisten = nextUnlisten; + } + }) + .catch((listenError) => { + if (!cancelled) setError(errorMessage(listenError)); + }); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [handleAgentEvent, userId]); + + if (!isTauriDesktop()) { + return ( +
+
+ +

Agent Mode is available in Maple Desktop.

+
+
+ ); + } + + return ( +
+ void createSession()} + onProjectRootChange={selectProjectRoot} + onSessionDelete={setSessionToDelete} + onSessionSelect={(sessionId) => void loadSession(sessionId)} + /> + } + onToggle={toggleSidebar} + /> + + {sessionToDelete ? ( + { + if (!open) setSessionToDelete(null); + }} + chatTitle={sessionTitle(sessionToDelete)} + description={`This will delete "${sessionTitle(sessionToDelete)}" from Agent Mode. This action cannot be undone.`} + onConfirm={() => void deleteSession(sessionToDelete.id)} + /> + ) : null} + +
+ {!isSidebarOpen && ( +
+ + +
+ )} + + {hasManualProxyConflict && ( +
+
+
+ +
+

Saved local proxy credential

+

+ Maple cannot verify that this existing Local OpenAI Proxy key belongs to the + signed-in account. This can happen once after upgrading from an older Agent Mode + build. Your chats remain available; replace the saved local setup before sending + another message. The existing backend key will remain in API Management. +

+
+
+ +
+
+ )} + + {error && ( +
+
+ + {error} +
+
+ )} + +
+
+
+ {timelineItems.length === 0 ? ( + void sendMessage()} + /> + ) : ( + + )} +
+
+ + {timelineItems.length > 0 ? ( +
+
+ void sendMessage()} + /> +
+
+ ) : null} +
+
+
+ ); +} + +function EmptyAgentState(props: AgentComposerProps) { + return ( +
+
+

+ Work in a folder... +

+ +
+
+ ); +} + +interface AgentSidebarContentProps { + activeSessionId: string | null; + isCompactLayout: boolean; + projectRoot: string; + recentRoots: RecentProjectRoot[]; + completedUnreadSessionIds: Set; + disabled: boolean; + runningSessionIds: Set; + sessions: AgentSessionSummary[]; + onChooseProjectRoot: () => void; + onCreateSession: () => void; + onProjectRootChange: (value: string) => void; + onSessionDelete: (session: AgentSessionSummary) => void; + onSessionSelect: (sessionId: string) => void; +} + +function AgentSidebarContent({ + activeSessionId, + isCompactLayout, + projectRoot, + recentRoots, + completedUnreadSessionIds, + disabled, + runningSessionIds, + sessions, + onChooseProjectRoot, + onCreateSession, + onProjectRootChange, + onSessionDelete, + onSessionSelect +}: AgentSidebarContentProps) { + const rowElementsRef = useRef(new Map()); + const previousRowTopsRef = useRef(new Map()); + const [collapsedProjectRoots, setCollapsedProjectRoots] = useState>(() => new Set()); + const { projectRows, sessionsByRoot } = useMemo(() => { + const rootsByPath = new Map(); + const sessionsByProjectRoot = new Map(); + + recentRoots.forEach((root) => rootsByPath.set(root.path, root)); + if (projectRoot && !rootsByPath.has(projectRoot)) { + rootsByPath.set(projectRoot, { + path: projectRoot, + name: basename(projectRoot), + lastUsedMs: Date.now() + }); + } + + sessions.forEach((session) => { + const rootSessions = sessionsByProjectRoot.get(session.projectRoot) || []; + rootSessions.push(session); + sessionsByProjectRoot.set(session.projectRoot, rootSessions); + + const existingRoot = rootsByPath.get(session.projectRoot); + if (!existingRoot || existingRoot.lastUsedMs < session.updatedMs) { + rootsByPath.set(session.projectRoot, { + path: session.projectRoot, + name: basename(session.projectRoot), + lastUsedMs: session.updatedMs + }); + } + }); + + sessionsByProjectRoot.forEach((rootSessions) => { + rootSessions.sort((a, b) => b.updatedMs - a.updatedMs); + }); + + const rows = [...rootsByPath.values()].sort((a, b) => { + return b.lastUsedMs - a.lastUsedMs; + }); + + return { projectRows: rows, sessionsByRoot: sessionsByProjectRoot }; + }, [projectRoot, recentRoots, sessions]); + const setAnimatedRowRef = useCallback((key: string, node: HTMLElement | null) => { + if (node) { + rowElementsRef.current.set(key, node); + } else { + rowElementsRef.current.delete(key); + } + }, []); + + useLayoutEffect(() => { + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const previousTops = previousRowTopsRef.current; + const nextTops = new Map(); + + rowElementsRef.current.forEach((node, key) => { + const nextTop = node.getBoundingClientRect().top; + nextTops.set(key, nextTop); + + if (prefersReducedMotion) return; + + const previousTop = previousTops.get(key); + if (previousTop === undefined) return; + + const delta = previousTop - nextTop; + if (Math.abs(delta) < 1) return; + + node.animate([{ transform: `translateY(${delta}px)` }, { transform: "translateY(0)" }], { + duration: SIDEBAR_REORDER_ANIMATION_MS, + easing: "cubic-bezier(0.2, 0, 0, 1)" + }); + }); + + previousRowTopsRef.current = nextTops; + }, [collapsedProjectRoots, projectRows, sessions]); + + const toggleProjectCollapsed = useCallback((path: string) => { + setCollapsedProjectRoots((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + return ( + <> +
+

+ Projects +

+ +
+ + {projectRows.length === 0 ? ( + + ) : ( +
+ {projectRows.map((root) => { + const isActive = root.path === projectRoot; + const projectSessions = sessionsByRoot.get(root.path) || []; + const isCollapsed = collapsedProjectRoots.has(root.path); + const hasRunningSession = projectSessions.some((session) => + runningSessionIds.has(session.id) + ); + const hasUnreadCompletedSession = projectSessions.some((session) => + completedUnreadSessionIds.has(session.id) + ); + const showProjectRunningIndicator = isCollapsed && hasRunningSession; + const showProjectUnreadIndicator = + isCollapsed && !hasRunningSession && hasUnreadCompletedSession; + + return ( +
setAnimatedRowRef(`project:${root.path}`, node)} + className="space-y-2 will-change-transform" + > +
+ + {projectSessions.length > 0 ? ( + + ) : null} + {isActive ? ( + + ) : null} +
+ + {!isCollapsed ? ( +
+ {projectSessions.length === 0 ? ( + isActive ? ( +

No sessions yet

+ ) : null + ) : ( + projectSessions.map((session) => { + const isActiveSession = session.id === activeSessionId; + const isRunning = runningSessionIds.has(session.id); + const isUnreadCompleted = completedUnreadSessionIds.has(session.id); + const title = sessionTitle(session); + const accessibleStatus = isRunning + ? "running" + : isUnreadCompleted + ? "completed, unread" + : null; + + return ( +
setAnimatedRowRef(`session:${session.id}`, node)} + className="group relative isolate flex w-full min-w-0 select-none items-stretch gap-0.5 rounded-2xl will-change-transform" + onContextMenu={(event) => event.preventDefault()} + > + + +
+ +
+ ); + }) + )} +
+ ) : null} +
+ ); + })} +
+ )} + +
+

+ Chats +

+

+ Folderless agent chats are not available yet. +

+
+ + ); +} + +interface AgentComposerProps { + activeRootLabel: string; + areSettingsDisabled: boolean; + input: string; + isSendDisabled: boolean; + isSending: boolean; + isStarting: boolean; + mode: AgentPermissionMode; + model: string; + projectRoot: string; + recentRoots: RecentProjectRoot[]; + onCancelPrompt: () => void; + onChooseProjectRoot: () => void; + onInputChange: (value: string) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + onModeChange: (value: AgentPermissionMode) => void; + onModelChange: (value: string) => void; + onProjectRootChange: (value: string) => void; + onSendMessage: () => void; +} + +function AgentComposer({ + activeRootLabel, + areSettingsDisabled, + input, + isSendDisabled, + isSending, + isStarting, + mode, + model, + projectRoot, + recentRoots, + onCancelPrompt, + onChooseProjectRoot, + onInputChange, + onKeyDown, + onModeChange, + onModelChange, + onProjectRootChange, + onSendMessage +}: AgentComposerProps) { + const rootOptions = recentRoots.some((root) => root.path === projectRoot) + ? recentRoots + : projectRoot + ? [{ path: projectRoot, name: activeRootLabel, lastUsedMs: Date.now() }, ...recentRoots] + : recentRoots; + + return ( +
+