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
+ {logoutError ? (
+
+ {logoutError}
+
+ ) : null}
+
-
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 96b661cc..9fc0b5c0 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -6,13 +6,22 @@ import {
XCircle,
Trash2,
X,
- FolderInput
+ FolderInput,
+ Bot
} from "lucide-react";
import { Button } from "./ui/button";
import { useLocation, useRouter } from "@tanstack/react-router";
import { ChatHistoryList } from "./ChatHistoryList";
import { AccountMenu } from "./AccountMenu";
-import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect, useState } from "react";
+import {
+ useRef,
+ useEffect,
+ KeyboardEvent,
+ useCallback,
+ useLayoutEffect,
+ useState,
+ type ReactNode
+} from "react";
import { flushSync } from "react-dom";
import { cn, useClickOutside, useIsMobile, useIsLandscapeMobile } from "@/utils/utils";
import { MapleWordmark } from "@/components/MapleWordmark";
@@ -23,14 +32,19 @@ import {
SIDEBAR_MAX_WIDTH_CLASS,
SIDEBAR_WIDTH_CLASS
} from "@/constants/layout";
+import { isTauriDesktop } from "@/utils/platform";
export function Sidebar({
chatId,
isOpen,
+ mode = "chat",
+ navigationContent,
onToggle
}: {
chatId?: string;
isOpen: boolean;
+ mode?: "chat" | "agent";
+ navigationContent?: ReactNode;
onToggle: () => void;
}) {
const router = useRouter();
@@ -111,6 +125,18 @@ export function Sidebar({
}
}
+ async function toggleAgentMode() {
+ if (isOpen) {
+ onToggle();
+ }
+
+ try {
+ await router.navigate({ to: location.pathname === "/agent" ? "/" : "/agent" });
+ } catch (error) {
+ console.error("Navigation failed:", error);
+ }
+ }
+
const toggleSearch = () => {
setIsSearchVisible(!isSearchVisible);
if (!isSearchVisible) {
@@ -140,6 +166,8 @@ export function Sidebar({
const isMobile = useIsMobile();
const isLandscapeMobile = useIsLandscapeMobile();
const isCompactLayout = isMobile || isLandscapeMobile;
+ const showAgentMode = isTauriDesktop();
+ const isAgentMode = mode === "agent";
// Modified click outside handler to ignore clicks in dropdowns and dialogs
// Only applies on mobile - desktop users use the toggle button
@@ -235,17 +263,34 @@ export function Sidebar({
New Chat
-
-
- Search
-
+ {showAgentMode && (
+
+
+ Agent Mode
+
+ )}
+ {!isAgentMode && (
+
+
+ Search
+
+ )}
- {isSelectionMode && (
+ {!isAgentMode && isSelectionMode && (
)}
- {isSearchVisible && (
+ {!isAgentMode && isSearchVisible && (
-
+ {navigationContent || (
+
+ )}
{/* Real empty tail so the last row sits in clear space — no overlay on hit targets */}
@@ -331,7 +378,7 @@ export function Sidebar({
aria-hidden
className={cn(
"pointer-events-none absolute left-0 top-0 z-[8] h-8 w-[calc(100%-10px)] max-w-full bg-gradient-to-b to-transparent",
- isSearchVisible
+ !isAgentMode && isSearchVisible
? "from-background/75 dark:from-background/75"
: "from-muted/75 dark:from-[hsl(var(--sidebar)/0.75)]"
)}
diff --git a/frontend/src/components/VerificationModal.tsx b/frontend/src/components/VerificationModal.tsx
index bc16cbd8..8a67ee55 100644
--- a/frontend/src/components/VerificationModal.tsx
+++ b/frontend/src/components/VerificationModal.tsx
@@ -14,6 +14,8 @@ import { Loader2, CheckCircle, LogOut } from "lucide-react";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { AlertDestructive } from "./AlertDestructive";
+import { stopAgentRuntimeForUser } from "@/services/agentRuntimeService";
+import { getBillingService } from "@/billing/billingService";
export function VerificationModal() {
const os = useOpenSecret();
@@ -30,6 +32,8 @@ export function VerificationModal() {
const [verificationCode, setVerificationCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [error, setError] = useState
(null);
+ const [isSigningOut, setIsSigningOut] = useState(false);
+ const [signOutError, setSignOutError] = useState(null);
// Update open state based on user verification status
useEffect(() => {
@@ -108,16 +112,49 @@ export function VerificationModal() {
};
const handleSignOut = async () => {
- // Stop proxy and reset config so it doesn't auto-start on next launch
+ setSignOutError(null);
+ setIsSigningOut(true);
+ let operationBlock: Awaited> | null = null;
+ let signedOut = false;
+
try {
- const { proxyService } = await import("@/services/proxyService");
- await proxyService.stopAndResetProxy();
+ operationBlock = await stopAgentRuntimeForUser(os.auth.user?.user.id);
} catch (error) {
- console.error("Error clearing proxy config:", error);
+ console.error("Error stopping Agent Mode:", error);
+ setSignOutError("Maple couldn't stop Agent Mode. Please try logging out again.");
+ setIsSigningOut(false);
+ return;
}
- await os.signOut();
- queryClient.clear();
+ 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);
+
+ // Do not carry this account's third-party billing JWT into the next
+ // authenticated session in the same WebView.
+ try {
+ getBillingService().clearToken();
+ } catch {
+ sessionStorage.removeItem("maple_billing_token");
+ }
+
+ await os.signOut();
+ signedOut = true;
+ queryClient.clear();
+ } catch (error) {
+ console.error("Error during sign out:", error);
+ setSignOutError(
+ "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 (
@@ -155,6 +192,9 @@ export function VerificationModal() {
{error && }
+ {signOutError ? (
+
+ ) : null}
{justResent ? (
@@ -172,9 +212,14 @@ export function VerificationModal() {
)}
)}
-
+
- Log Out
+ {isSigningOut ? "Logging Out..." : "Log Out"}
diff --git a/frontend/src/components/apikeys/ProxyConfigSection.tsx b/frontend/src/components/apikeys/ProxyConfigSection.tsx
index d467f4aa..f602ea2b 100644
--- a/frontend/src/components/apikeys/ProxyConfigSection.tsx
+++ b/frontend/src/components/apikeys/ProxyConfigSection.tsx
@@ -107,7 +107,10 @@ export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigS
backend_url: backendUrl,
auto_start: config.auto_start // Preserve auto_start setting
};
- const status = await proxyService.startProxy(updatedConfig);
+ // The user is explicitly taking control of the shared proxy. The service
+ // detaches the active Agent association only after the native start
+ // succeeds, while retaining its exact key name for later revocation.
+ const status = await proxyService.startManualProxy(updatedConfig);
setProxyStatus(status);
setConfig(updatedConfig);
@@ -126,7 +129,7 @@ export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigS
setIsLoading(true);
try {
- const status = await proxyService.stopProxy();
+ const status = await proxyService.stopManualProxy();
setProxyStatus(status);
setConfig((prev) => ({ ...prev, enabled: false }));
@@ -313,7 +316,7 @@ export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigS
setConfig(newConfig);
// Save immediately when toggling auto-start
try {
- await proxyService.saveProxySettings(newConfig);
+ await proxyService.saveManualProxySettings(newConfig);
setMessage({
type: "success",
text: e.target.checked ? "Auto-start enabled" : "Auto-start disabled"
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index c469da3a..4de9e41c 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as PasswordResetRouteImport } from './routes/password-reset'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DownloadsRouteImport } from './routes/downloads'
import { Route as DesktopAuthRouteImport } from './routes/desktop-auth'
+import { Route as AgentRouteImport } from './routes/agent'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as VerifyCodeRouteImport } from './routes/verify.$code'
@@ -88,6 +89,11 @@ const DesktopAuthRoute = DesktopAuthRouteImport.update({
path: '/desktop-auth',
getParentRoute: () => rootRouteImport,
} as any)
+const AgentRoute = AgentRouteImport.update({
+ id: '/agent',
+ path: '/agent',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
@@ -122,6 +128,7 @@ const AuthProviderCallbackRoute = AuthProviderCallbackRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/agent': typeof AgentRoute
'/desktop-auth': typeof DesktopAuthRoute
'/downloads': typeof DownloadsRoute
'/login': typeof LoginRoute
@@ -142,6 +149,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/agent': typeof AgentRoute
'/desktop-auth': typeof DesktopAuthRoute
'/downloads': typeof DownloadsRoute
'/login': typeof LoginRoute
@@ -163,6 +171,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/agent': typeof AgentRoute
'/desktop-auth': typeof DesktopAuthRoute
'/downloads': typeof DownloadsRoute
'/login': typeof LoginRoute
@@ -185,6 +194,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/about'
+ | '/agent'
| '/desktop-auth'
| '/downloads'
| '/login'
@@ -205,6 +215,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/about'
+ | '/agent'
| '/desktop-auth'
| '/downloads'
| '/login'
@@ -225,6 +236,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/about'
+ | '/agent'
| '/desktop-auth'
| '/downloads'
| '/login'
@@ -246,6 +258,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
+ AgentRoute: typeof AgentRoute
DesktopAuthRoute: typeof DesktopAuthRoute
DownloadsRoute: typeof DownloadsRoute
LoginRoute: typeof LoginRoute
@@ -349,6 +362,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DesktopAuthRouteImport
parentRoute: typeof rootRouteImport
}
+ '/agent': {
+ id: '/agent'
+ path: '/agent'
+ fullPath: '/agent'
+ preLoaderRoute: typeof AgentRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/about': {
id: '/about'
path: '/about'
@@ -409,6 +429,7 @@ const PasswordResetRouteWithChildren = PasswordResetRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
+ AgentRoute: AgentRoute,
DesktopAuthRoute: DesktopAuthRoute,
DownloadsRoute: DownloadsRoute,
LoginRoute: LoginRoute,
diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx
index 5ce20069..49fcab4e 100644
--- a/frontend/src/routes/__root.tsx
+++ b/frontend/src/routes/__root.tsx
@@ -2,6 +2,8 @@ import { useOpenSecret } from "@opensecret/react";
import { OpenSecretContextType } from "@opensecret/react";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { ExternalUrlConfirmHandler } from "@/components/ExternalUrlConfirmHandler";
+import { useLayoutEffect } from "react";
+import { transitionAgentAuthUser } from "@/services/agentRuntimeService";
interface RootRouterContext {
os: OpenSecretContextType;
@@ -30,6 +32,13 @@ export const Route = createRootRouteWithContext
()({
function Root() {
const { auth } = useOpenSecret();
+ const userId = auth.user?.user.id || null;
+
+ useLayoutEffect(() => {
+ // Queue cleanup before route-level passive effects initialize Agent Mode.
+ // A failed transition is surfaced by Agent Mode's matching wait gate.
+ void transitionAgentAuthUser(userId).catch(() => {});
+ }, [userId]);
// TODO... put something here, but showing nothing looks nicer than "Loading..."
if (auth.loading) {
diff --git a/frontend/src/routes/agent.tsx b/frontend/src/routes/agent.tsx
new file mode 100644
index 00000000..5543f881
--- /dev/null
+++ b/frontend/src/routes/agent.tsx
@@ -0,0 +1,33 @@
+import { Navigate, createFileRoute } from "@tanstack/react-router";
+import { useOpenSecret } from "@opensecret/react";
+import { AppEntryPage } from "@/components/AppEntryPage";
+import { useRouteMeta } from "@/utils/routeMeta";
+import { appUrl } from "@/config/domains";
+import { isTauriDesktop } from "@/utils/platform";
+import { AgentMode } from "@/components/AgentMode";
+
+export const Route = createFileRoute("/agent")({
+ component: AgentRoute
+});
+
+function AgentRoute() {
+ const os = useOpenSecret();
+ const agentModeAvailable = isTauriDesktop();
+
+ useRouteMeta({
+ title: agentModeAvailable && os.auth.user ? "Maple Agent Mode" : "Maple AI",
+ description: "Maple Agent Mode.",
+ canonicalUrl: appUrl("/agent")
+ });
+
+ if (!agentModeAvailable) {
+ return ;
+ }
+
+ if (!os.auth.user) {
+ return ;
+ }
+
+ const userId = os.auth.user.user.id;
+ return ;
+}
diff --git a/frontend/src/services/agentAuthLifecycle.test.ts b/frontend/src/services/agentAuthLifecycle.test.ts
new file mode 100644
index 00000000..fad15759
--- /dev/null
+++ b/frontend/src/services/agentAuthLifecycle.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, test } from "bun:test";
+import { AgentAuthLifecycleCoordinator } from "./agentAuthLifecycle";
+
+describe("AgentAuthLifecycleCoordinator", () => {
+ test("cleans the previous account before activating the next one", async () => {
+ const events: string[] = [];
+ const coordinator = new AgentAuthLifecycleCoordinator(
+ async (userId) => {
+ events.push(`cleanup:${userId}`);
+ },
+ (userId) => events.push(`activate:${userId}`)
+ );
+
+ await coordinator.transitionTo("user-a");
+ events.length = 0;
+ await coordinator.transitionTo("user-b");
+ await coordinator.waitForUser("user-b");
+
+ expect(events).toEqual(["cleanup:user-a", "activate:user-b"]);
+ });
+
+ test("drains every skipped account during rapid transitions", async () => {
+ const events: string[] = [];
+ let releaseCleanup: (() => void) | undefined;
+ const cleanupGate = new Promise((resolve) => {
+ releaseCleanup = resolve;
+ });
+ const coordinator = new AgentAuthLifecycleCoordinator(
+ async (userId) => {
+ events.push(`cleanup:${userId}`);
+ if (userId === "user-a") await cleanupGate;
+ },
+ (userId) => events.push(`activate:${userId}`)
+ );
+
+ await coordinator.transitionTo("user-a");
+ events.length = 0;
+ const toB = coordinator.transitionTo("user-b");
+ const toC = coordinator.transitionTo("user-c");
+ releaseCleanup?.();
+ await Promise.all([toB, toC]);
+
+ expect(events.slice(0, 2)).toEqual(["cleanup:user-a", "cleanup:user-b"]);
+ expect(events.at(-1)).toBe("activate:user-c");
+ expect(events).not.toContain("activate:user-b");
+ });
+
+ test("retains a failed cleanup target for the next transition retry", async () => {
+ const attempts: string[] = [];
+ let shouldFail = true;
+ const coordinator = new AgentAuthLifecycleCoordinator(
+ async (userId) => {
+ attempts.push(userId);
+ if (shouldFail) throw new Error("offline");
+ },
+ () => {}
+ );
+
+ await coordinator.transitionTo("user-a");
+ await expect(coordinator.transitionTo("user-b")).rejects.toThrow("offline");
+ shouldFail = false;
+ await coordinator.transitionTo("user-c");
+
+ expect(attempts).toEqual(["user-a", "user-a", "user-b"]);
+ await coordinator.waitForUser("user-c");
+ });
+});
diff --git a/frontend/src/services/agentAuthLifecycle.ts b/frontend/src/services/agentAuthLifecycle.ts
new file mode 100644
index 00000000..f9ffe259
--- /dev/null
+++ b/frontend/src/services/agentAuthLifecycle.ts
@@ -0,0 +1,56 @@
+export type AgentAccountCleanup = (userId: string) => Promise;
+export type AgentAccountActivation = (userId: string) => void;
+
+/**
+ * Serializes authenticated-user transitions around Agent Mode cleanup.
+ *
+ * Cleanup targets are retained until they succeed, so a rapid A -> B -> C
+ * transition cannot activate C while A or B still owns a runtime/proxy. The
+ * coordinator deliberately contains no React or Tauri dependencies so the
+ * ordering contract can be tested directly.
+ */
+export class AgentAuthLifecycleCoordinator {
+ private currentUserId: string | null = null;
+ private readonly pendingCleanupUserIds = new Set();
+ private tail: Promise = Promise.resolve();
+
+ constructor(
+ private readonly cleanupAccount: AgentAccountCleanup,
+ private readonly activateAccount: AgentAccountActivation
+ ) {}
+
+ transitionTo(nextUserId: string | null): Promise {
+ const previousUserId = this.currentUserId;
+ if (previousUserId === nextUserId && this.pendingCleanupUserIds.size === 0) {
+ return this.tail;
+ }
+
+ if (previousUserId && previousUserId !== nextUserId) {
+ this.pendingCleanupUserIds.add(previousUserId);
+ }
+ this.currentUserId = nextUserId;
+
+ const transition = this.tail
+ .catch(() => undefined)
+ .then(async () => {
+ while (this.pendingCleanupUserIds.size > 0) {
+ const userId = this.pendingCleanupUserIds.values().next().value as string;
+ await this.cleanupAccount(userId);
+ this.pendingCleanupUserIds.delete(userId);
+ }
+
+ if (this.currentUserId) {
+ this.activateAccount(this.currentUserId);
+ }
+ });
+ this.tail = transition;
+ return transition;
+ }
+
+ async waitForUser(userId: string): Promise {
+ await this.tail;
+ if (this.currentUserId !== userId) {
+ throw new Error("Agent Mode authentication changed before initialization completed");
+ }
+ }
+}
diff --git a/frontend/src/services/agentModels.test.ts b/frontend/src/services/agentModels.test.ts
new file mode 100644
index 00000000..6e282e21
--- /dev/null
+++ b/frontend/src/services/agentModels.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test } from "bun:test";
+import { POWERFUL_MODEL_ALIAS, QUICK_MODEL_ALIAS } from "@/utils/utils";
+import {
+ DEFAULT_AGENT_MODEL,
+ PRIMARY_AGENT_MODEL_IDS,
+ fallbackAgentModel,
+ reconcileAgentModel
+} from "./agentModels";
+
+const models = [{ id: DEFAULT_AGENT_MODEL }, { id: "kimi-k2-6" }];
+
+describe("Agent Mode model defaults", () => {
+ test("promotes GLM first and leaves Kimi out of the primary choices", () => {
+ expect(PRIMARY_AGENT_MODEL_IDS).toEqual([DEFAULT_AGENT_MODEL, QUICK_MODEL_ALIAS]);
+ expect(PRIMARY_AGENT_MODEL_IDS).not.toContain("kimi-k2-6");
+ });
+
+ test("falls back to GLM when it is available, otherwise Quick", () => {
+ expect(fallbackAgentModel(models)).toBe(DEFAULT_AGENT_MODEL);
+ expect(fallbackAgentModel([{ id: "kimi-k2-6" }])).toBe(QUICK_MODEL_ALIAS);
+ });
+
+ test("keeps selectable concrete models and existing aliases", () => {
+ expect(reconcileAgentModel("kimi-k2-6", models)).toBe("kimi-k2-6");
+ expect(reconcileAgentModel(QUICK_MODEL_ALIAS, models)).toBe(QUICK_MODEL_ALIAS);
+ expect(reconcileAgentModel(POWERFUL_MODEL_ALIAS, models)).toBe(POWERFUL_MODEL_ALIAS);
+ });
+
+ test("replaces a missing concrete model with the best available default", () => {
+ expect(reconcileAgentModel("retired-model", models)).toBe(DEFAULT_AGENT_MODEL);
+ expect(reconcileAgentModel(DEFAULT_AGENT_MODEL, [{ id: "kimi-k2-6" }])).toBe(QUICK_MODEL_ALIAS);
+ });
+});
diff --git a/frontend/src/services/agentModels.ts b/frontend/src/services/agentModels.ts
new file mode 100644
index 00000000..1a66461a
--- /dev/null
+++ b/frontend/src/services/agentModels.ts
@@ -0,0 +1,23 @@
+import { POWERFUL_MODEL_ALIAS, QUICK_MODEL_ALIAS } from "@/utils/utils";
+
+export const DEFAULT_AGENT_MODEL = "glm-5-2";
+export const PRIMARY_AGENT_MODEL_IDS = [DEFAULT_AGENT_MODEL, QUICK_MODEL_ALIAS] as const;
+
+type AgentModelReference = {
+ id: string;
+};
+
+export function fallbackAgentModel(models: AgentModelReference[]): string {
+ return models.some((model) => model.id === DEFAULT_AGENT_MODEL)
+ ? DEFAULT_AGENT_MODEL
+ : QUICK_MODEL_ALIAS;
+}
+
+export function reconcileAgentModel(currentModel: string, models: AgentModelReference[]): string {
+ if (!currentModel) return fallbackAgentModel(models);
+ if (currentModel === QUICK_MODEL_ALIAS || currentModel === POWERFUL_MODEL_ALIAS) {
+ return currentModel;
+ }
+ if (models.some((model) => model.id === currentModel)) return currentModel;
+ return fallbackAgentModel(models);
+}
diff --git a/frontend/src/services/agentOperationFence.test.ts b/frontend/src/services/agentOperationFence.test.ts
new file mode 100644
index 00000000..9dffff2b
--- /dev/null
+++ b/frontend/src/services/agentOperationFence.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "bun:test";
+
+import { AgentOperationFence, AgentOperationsBlockedError } from "./agentOperationFence";
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((nextResolve) => {
+ resolve = nextResolve;
+ });
+ return { promise, resolve };
+}
+
+describe("AgentOperationFence", () => {
+ it("blocks new work and waits for in-flight work before granting cleanup", async () => {
+ const fence = new AgentOperationFence();
+ const pending = deferred();
+ const operation = fence.run("user-a", async () => await pending.promise);
+
+ await Promise.resolve();
+ let blockGranted = false;
+ const blockPromise = fence.blockAndDrain("user-a").then((block) => {
+ blockGranted = true;
+ return block;
+ });
+
+ await expect(fence.run("user-a", async () => "late work")).rejects.toBeInstanceOf(
+ AgentOperationsBlockedError
+ );
+ expect(blockGranted).toBe(false);
+
+ pending.resolve();
+ await operation;
+ const block = await blockPromise;
+ expect(blockGranted).toBe(true);
+
+ block.release();
+ await expect(fence.run("user-a", async () => "resumed")).resolves.toBe("resumed");
+ });
+
+ it("invalidates queued work when cleanup wins the generation race", async () => {
+ const fence = new AgentOperationFence();
+ let ran = false;
+ const operation = fence.run("user-a", async () => {
+ ran = true;
+ });
+ const block = await fence.blockAndDrain("user-a");
+
+ await expect(operation).rejects.toBeInstanceOf(AgentOperationsBlockedError);
+ expect(ran).toBe(false);
+ block.release();
+ });
+
+ it("drains an external await and rejects its late nested operation", async () => {
+ const fence = new AgentOperationFence();
+ const external = deferred();
+ let nestedRan = false;
+ const workflow = fence.run("user-a", async () => {
+ await external.promise;
+ await fence.run("user-a", async () => {
+ nestedRan = true;
+ });
+ });
+ await Promise.resolve();
+
+ const blockPromise = fence.blockAndDrain("user-a");
+ external.resolve();
+ const block = await blockPromise;
+
+ await expect(workflow).rejects.toBeInstanceOf(AgentOperationsBlockedError);
+ expect(nestedRan).toBe(false);
+ block.release();
+ });
+
+ it("isolates accounts and keeps blocking until every lease releases", async () => {
+ const fence = new AgentOperationFence();
+ const first = await fence.blockAndDrain("user-a");
+ const second = await fence.blockAndDrain("user-a");
+
+ await expect(fence.run("user-b", async () => "ok")).resolves.toBe("ok");
+ first.release();
+ await expect(fence.run("user-a", async () => "blocked")).rejects.toBeInstanceOf(
+ AgentOperationsBlockedError
+ );
+
+ second.release();
+ await expect(fence.run("user-a", async () => "ok")).resolves.toBe("ok");
+ });
+
+ it("keeps a completed logout blocked until a new authenticated session activates", async () => {
+ const fence = new AgentOperationFence();
+ const block = await fence.blockAndDrain("user-a");
+ block.retainUntilNextSession();
+
+ await expect(fence.run("user-a", async () => "stale")).rejects.toBeInstanceOf(
+ AgentOperationsBlockedError
+ );
+ fence.activateUserSession("user-a");
+ await expect(fence.run("user-a", async () => "new session")).resolves.toBe("new session");
+ });
+
+ it("does not release an active cleanup lease when a component activates", async () => {
+ const fence = new AgentOperationFence();
+ const block = await fence.blockAndDrain("user-a");
+
+ fence.activateUserSession("user-a");
+ await expect(fence.run("user-a", async () => "blocked")).rejects.toBeInstanceOf(
+ AgentOperationsBlockedError
+ );
+ block.release();
+ });
+});
diff --git a/frontend/src/services/agentOperationFence.ts b/frontend/src/services/agentOperationFence.ts
new file mode 100644
index 00000000..78c9b8fb
--- /dev/null
+++ b/frontend/src/services/agentOperationFence.ts
@@ -0,0 +1,92 @@
+export class AgentOperationsBlockedError extends Error {
+ constructor() {
+ super("Agent Mode is stopping for this account");
+ this.name = "AgentOperationsBlockedError";
+ }
+}
+
+export interface AgentOperationBlock {
+ release(): void;
+ retainUntilNextSession(): void;
+}
+
+interface UserOperationState {
+ generation: number;
+ blockers: Map;
+ inFlight: Set>;
+}
+
+export class AgentOperationFence {
+ private readonly users = new Map();
+
+ async run(userId: string, operation: () => Promise): Promise {
+ const state = this.stateFor(userId);
+ if (state.blockers.size > 0) throw new AgentOperationsBlockedError();
+
+ const generation = state.generation;
+ const tracked = Promise.resolve().then(async () => {
+ if (state.blockers.size > 0 || state.generation !== generation) {
+ throw new AgentOperationsBlockedError();
+ }
+ return await operation();
+ });
+ state.inFlight.add(tracked);
+
+ try {
+ return await tracked;
+ } finally {
+ state.inFlight.delete(tracked);
+ }
+ }
+
+ async blockAndDrain(userId: string): Promise {
+ const state = this.stateFor(userId);
+ const token = Symbol("agent-operation-block");
+ state.blockers.set(token, { releaseOnActivation: false });
+ state.generation += 1;
+
+ while (state.inFlight.size > 0) {
+ await Promise.allSettled([...state.inFlight]);
+ }
+
+ let released = false;
+ return {
+ release: () => {
+ if (released) return;
+ released = true;
+ state.blockers.delete(token);
+ },
+ retainUntilNextSession: () => {
+ if (released) return;
+ const blocker = state.blockers.get(token);
+ if (blocker) blocker.releaseOnActivation = true;
+ }
+ };
+ }
+
+ activateUserSession(userId: string): void {
+ const state = this.stateFor(userId);
+ if (state.blockers.size === 0) return;
+ for (const [token, blocker] of state.blockers) {
+ if (blocker.releaseOnActivation) state.blockers.delete(token);
+ }
+ state.generation += 1;
+ }
+
+ private stateFor(userId: string): UserOperationState {
+ if (!userId.trim()) throw new Error("Agent operations require an authenticated user");
+
+ let state = this.users.get(userId);
+ if (!state) {
+ state = {
+ generation: 0,
+ blockers: new Map(),
+ inFlight: new Set()
+ };
+ this.users.set(userId, state);
+ }
+ return state;
+ }
+}
+
+export const agentOperationFence = new AgentOperationFence();
diff --git a/frontend/src/services/agentRuntimeService.ts b/frontend/src/services/agentRuntimeService.ts
new file mode 100644
index 00000000..a2964ce3
--- /dev/null
+++ b/frontend/src/services/agentRuntimeService.ts
@@ -0,0 +1,288 @@
+import { isTauriDesktop } from "@/utils/platform";
+import { agentOperationFence, type AgentOperationBlock } from "@/services/agentOperationFence";
+import { AgentAuthLifecycleCoordinator } from "@/services/agentAuthLifecycle";
+
+export interface AgentConfig {
+ defaultProjectRoot?: string | null;
+ defaultModel: string;
+}
+
+export interface AgentStartRequest {
+ projectRoot?: string | null;
+ model?: string | null;
+ mode?: string | null;
+}
+
+export interface AgentRuntimeStatus {
+ running: boolean;
+ projectRoot?: string | null;
+ model?: string | null;
+ mode?: string | null;
+ activeRuns?: Record;
+}
+
+export interface RecentProjectRoot {
+ path: string;
+ name: string;
+ lastUsedMs: number;
+}
+
+export interface AgentCreateSessionRequest {
+ projectRoot?: string | null;
+ title?: string | null;
+ model?: string | null;
+ mode?: string | null;
+}
+
+export interface AgentSessionSummary {
+ id: string;
+ title: string;
+ projectRoot: string;
+ createdMs: number;
+ updatedMs: number;
+ messageCount: number;
+ model?: string | null;
+ mode: string;
+}
+
+export interface AgentTimelineItem {
+ id: string;
+ itemType: "message" | "thinking" | "tool" | "permission" | "system" | "error";
+ role?: "user" | "assistant" | "thought" | "system" | string | null;
+ title?: string | null;
+ text?: string | null;
+ status?: string | null;
+ input?: unknown;
+ output?: unknown;
+ createdMs: number;
+ merge: "append" | "replace" | string;
+}
+
+export interface AgentSessionDetail {
+ session: AgentSessionSummary;
+ timeline: AgentTimelineItem[];
+}
+
+export interface AgentSendMessageRequest {
+ sessionId: string;
+ text: string;
+ model?: string | null;
+ mode?: string | null;
+}
+
+export interface AgentRunResponse {
+ runId: string;
+}
+
+export type AgentPermissionDecision =
+ | "allow_once"
+ | "always_allow"
+ | "deny_once"
+ | "always_deny"
+ | "cancel";
+
+export interface AgentEventEnvelope {
+ eventType: string;
+ sessionId?: string | null;
+ runId?: string | null;
+ item?: AgentTimelineItem | null;
+ status?: AgentRuntimeStatus | null;
+ session?: AgentSessionSummary | null;
+ message?: string | null;
+}
+
+export type AgentEventHandler = (event: AgentEventEnvelope) => void;
+export type UnlistenAgentEvents = () => void;
+
+class AgentRuntimeService {
+ async getRuntimeStatus(userId: string): Promise {
+ return await this.invokeForUser(userId, "agent_get_runtime_status");
+ }
+
+ async startRuntime(userId: string, request?: AgentStartRequest): Promise {
+ return await this.invokeForUser(userId, "agent_start_runtime", {
+ userId,
+ request: request ?? null
+ });
+ }
+
+ async restartRuntime(userId: string, request?: AgentStartRequest): Promise {
+ return await this.invokeForUser(userId, "agent_restart_runtime", {
+ userId,
+ request: request ?? null
+ });
+ }
+
+ async loadConfig(userId: string): Promise {
+ return await this.invokeForUser(userId, "agent_load_config");
+ }
+
+ async saveConfig(userId: string, config: AgentConfig): Promise {
+ await this.invokeForUser(userId, "agent_save_config", { userId, config });
+ }
+
+ async listRecentProjectRoots(userId: string): Promise {
+ return await this.invokeForUser(userId, "agent_list_recent_project_roots");
+ }
+
+ async saveRecentProjectRoot(userId: string, path: string): Promise {
+ return await this.invokeForUser(userId, "agent_save_recent_project_root", {
+ userId,
+ path
+ });
+ }
+
+ async createSession(
+ userId: string,
+ request?: AgentCreateSessionRequest
+ ): Promise {
+ return await this.invokeForUser(userId, "agent_create_session", {
+ userId,
+ request: request ?? null
+ });
+ }
+
+ async listSessions(userId: string, projectRoot?: string | null): Promise {
+ return await this.invokeForUser(userId, "agent_list_sessions", {
+ userId,
+ projectRoot: projectRoot ?? null
+ });
+ }
+
+ async loadSession(userId: string, sessionId: string): Promise {
+ return await this.invokeForUser(userId, "agent_load_session", {
+ userId,
+ sessionId
+ });
+ }
+
+ async deleteSession(userId: string, sessionId: string): Promise {
+ await this.invokeForUser(userId, "agent_delete_session", { userId, sessionId });
+ }
+
+ async sendMessage(userId: string, request: AgentSendMessageRequest): Promise {
+ return await this.invokeForUser(userId, "agent_send_message", {
+ userId,
+ request
+ });
+ }
+
+ async cancelRun(userId: string, runId: string): Promise {
+ await this.invokeForUser(userId, "agent_cancel_run", { userId, runId });
+ }
+
+ async respondToPermission(
+ userId: string,
+ sessionId: string,
+ requestId: string,
+ decision: AgentPermissionDecision
+ ): Promise {
+ await this.invokeForUser(userId, "agent_permission_respond", {
+ userId,
+ response: { sessionId, requestId, decision }
+ });
+ }
+
+ async listenToEvents(handler: AgentEventHandler): Promise {
+ if (!isTauriDesktop()) {
+ return () => {};
+ }
+ const { listen } = await import("@tauri-apps/api/event");
+ const unlisten = await listen("agent-event", (event) => {
+ handler(event.payload);
+ });
+ return unlisten;
+ }
+
+ private async invokeForUser(
+ userId: string,
+ command: string,
+ args?: Record
+ ): Promise {
+ return await agentOperationFence.run(userId, async () => {
+ return await invokeAgent(command, { userId, ...args });
+ });
+ }
+}
+
+async function invokeAgent(command: string, args?: Record): Promise {
+ if (!isTauriDesktop()) {
+ throw new Error("Agent Mode is available in Maple Desktop.");
+ }
+ const { invoke } = await import("@tauri-apps/api/core");
+ return await invoke(command, args);
+}
+
+export const agentRuntimeService = new AgentRuntimeService();
+
+const agentAuthLifecycle = new AgentAuthLifecycleCoordinator(
+ async (userId) => {
+ if (!isTauriDesktop()) return;
+ const block = await stopAgentRuntimeForUser(userId);
+ try {
+ // Auth may already be gone, so remote revocation is not reliable here.
+ // Scrub the local credential immediately; the exact tracked backend-key
+ // record remains available for retry if this account signs in again.
+ const { proxyService } = await import("@/services/proxyService");
+ await proxyService.stopAndResetProxy();
+ } finally {
+ block.retainUntilNextSession();
+ }
+ },
+ (userId) => agentOperationFence.activateUserSession(userId)
+);
+
+export function transitionAgentAuthUser(userId?: string | null): Promise {
+ return agentAuthLifecycle.transitionTo(userId || null);
+}
+
+export async function awaitAgentAuthUser(userId: string): Promise {
+ await agentAuthLifecycle.waitForUser(userId);
+}
+
+export async function stopAgentRuntimeForUser(
+ userId?: string | null
+): Promise {
+ if (!isTauriDesktop()) return noOpOperationBlock();
+ if (!userId) throw new Error("Cannot stop Agent Mode without an authenticated user");
+ const block = await agentOperationFence.blockAndDrain(userId);
+ try {
+ await invokeAgent("agent_stop_runtime", { userId });
+ return block;
+ } catch (error) {
+ block.release();
+ throw error;
+ }
+}
+
+export async function clearAgentDataForUser(userId?: string | null): Promise {
+ if (!isTauriDesktop()) return noOpOperationBlock();
+ if (!userId) throw new Error("Cannot clear Agent Mode data without an authenticated user");
+ const block = await agentOperationFence.blockAndDrain(userId);
+ try {
+ await invokeAgent("agent_clear_user_data", { userId });
+ return block;
+ } catch (error) {
+ block.release();
+ throw error;
+ }
+}
+
+export async function clearAgentHistoryForUser(
+ userId?: string | null
+): Promise {
+ if (!isTauriDesktop()) return noOpOperationBlock();
+ if (!userId) throw new Error("Cannot clear Agent Mode history without an authenticated user");
+ const block = await agentOperationFence.blockAndDrain(userId);
+ try {
+ await invokeAgent("agent_clear_user_history", { userId });
+ return block;
+ } catch (error) {
+ block.release();
+ throw error;
+ }
+}
+
+function noOpOperationBlock(): AgentOperationBlock {
+ return { release: () => {}, retainUntilNextSession: () => {} };
+}
diff --git a/frontend/src/services/agentTimeline.test.ts b/frontend/src/services/agentTimeline.test.ts
new file mode 100644
index 00000000..14a535cf
--- /dev/null
+++ b/frontend/src/services/agentTimeline.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test";
+import type { AgentTimelineItem } from "./agentRuntimeService";
+import { coalesceAdjacentThinkingItems, hasRenderableThinkingText } from "./agentTimeline";
+
+function thinking(id: string, text: string): AgentTimelineItem {
+ return {
+ id,
+ itemType: "thinking",
+ role: "thought",
+ title: "Thinking",
+ text,
+ createdMs: 0,
+ merge: "replace"
+ };
+}
+
+describe("hasRenderableThinkingText", () => {
+ test("hides only empty and whitespace-only merged thoughts", () => {
+ expect(hasRenderableThinkingText(undefined)).toBe(false);
+ expect(hasRenderableThinkingText(" \n\t ")).toBe(false);
+ });
+
+ test("preserves all model content, including punctuation-only chunks", () => {
+ expect(hasRenderableThinkingText("Inspecting.")).toBe(true);
+ expect(hasRenderableThinkingText("🤔")).toBe(true);
+ expect(hasRenderableThinkingText("=>")).toBe(true);
+ expect(hasRenderableThinkingText(".")).toBe(true);
+ expect(hasRenderableThinkingText("…")).toBe(true);
+ expect(hasRenderableThinkingText(". Inspecting")).toBe(true);
+ });
+});
+
+describe("coalesceAdjacentThinkingItems", () => {
+ test("joins consecutive Goose reasoning messages without classifying their content", () => {
+ const projected = coalesceAdjacentThinkingItems([
+ thinking("reasoning", "Inspecting"),
+ thinking("punctuation", ".")
+ ]);
+
+ expect(projected).toHaveLength(1);
+ expect(projected[0].text).toBe("Inspecting.");
+ });
+
+ test("keeps reasoning phases separated by a tool row distinct", () => {
+ const tool: AgentTimelineItem = {
+ id: "tool",
+ itemType: "tool",
+ createdMs: 0,
+ merge: "replace"
+ };
+ const projected = coalesceAdjacentThinkingItems([
+ thinking("before", "Before"),
+ tool,
+ thinking("after", "After")
+ ]);
+
+ expect(projected.map((item) => item.text)).toEqual(["Before", undefined, "After"]);
+ });
+});
diff --git a/frontend/src/services/agentTimeline.ts b/frontend/src/services/agentTimeline.ts
new file mode 100644
index 00000000..b2bd7ab8
--- /dev/null
+++ b/frontend/src/services/agentTimeline.ts
@@ -0,0 +1,20 @@
+import type { AgentTimelineItem } from "./agentRuntimeService";
+
+export function hasRenderableThinkingText(text: string | null | undefined): boolean {
+ return Boolean(text?.trim());
+}
+
+export function coalesceAdjacentThinkingItems(items: AgentTimelineItem[]): AgentTimelineItem[] {
+ return items.reduce((projected, item) => {
+ const previous = projected[projected.length - 1];
+ if (item.itemType === "thinking" && previous?.itemType === "thinking") {
+ projected[projected.length - 1] = {
+ ...previous,
+ text: `${previous.text ?? ""}${item.text ?? ""}`
+ };
+ return projected;
+ }
+ projected.push(item);
+ return projected;
+ }, []);
+}
diff --git a/frontend/src/services/proxyService.test.ts b/frontend/src/services/proxyService.test.ts
new file mode 100644
index 00000000..321c90e6
--- /dev/null
+++ b/frontend/src/services/proxyService.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it } from "bun:test";
+
+import {
+ addAgentProxyKeyRecord,
+ agentProxyConfigsMatch,
+ deactivateAgentProxyKeyRegistry,
+ enforceAgentProxySecurity,
+ manualProxyConfigsMatch,
+ removeAgentProxyKeyRecord,
+ shouldBlockOnOwnerlessProxy,
+ shouldResetAgentProxyOwner,
+ type AgentProxyKeyRegistry,
+ type ProxyConfig
+} from "./proxyService";
+
+const desiredConfig: ProxyConfig = {
+ host: "127.0.0.1",
+ port: 37615,
+ api_key: "workspace-key",
+ enabled: true,
+ enable_cors: true,
+ backend_url: "http://127.0.0.1:31938",
+ auto_start: false
+};
+
+describe("agentProxyConfigsMatch", () => {
+ it("accepts the same effective running configuration", () => {
+ expect(
+ agentProxyConfigsMatch(
+ {
+ ...desiredConfig,
+ host: "127.0.0.1",
+ api_key: " workspace-key ",
+ backend_url: "http://127.0.0.1:31938/"
+ },
+ desiredConfig
+ )
+ ).toBe(true);
+ });
+
+ it.each([
+ ["host", { host: "0.0.0.0" }],
+ ["port", { port: 8080 }],
+ ["API key", { api_key: "another-key" }],
+ ["backend", { backend_url: "https://enclave.trymaple.ai" }],
+ ["enabled state", { enabled: false }],
+ ["CORS behavior", { enable_cors: false }]
+ ])("rejects a mismatched %s", (_label, override) => {
+ expect(agentProxyConfigsMatch({ ...desiredConfig, ...override }, desiredConfig)).toBe(false);
+ });
+
+ it("does not restart solely for an auto-start preference change", () => {
+ expect(agentProxyConfigsMatch({ ...desiredConfig, auto_start: true }, desiredConfig)).toBe(
+ true
+ );
+ });
+});
+
+describe("enforceAgentProxySecurity", () => {
+ it("forces loopback binding and disables pre-auth auto-start", () => {
+ expect(
+ enforceAgentProxySecurity({
+ ...desiredConfig,
+ host: "0.0.0.0",
+ auto_start: true
+ })
+ ).toEqual({
+ ...desiredConfig,
+ host: "127.0.0.1",
+ auto_start: false
+ });
+ });
+});
+
+describe("manualProxyConfigsMatch", () => {
+ it("requires the native process to be running with the requested durable config", () => {
+ expect(manualProxyConfigsMatch(desiredConfig, desiredConfig)).toBe(true);
+ expect(manualProxyConfigsMatch({ ...desiredConfig, auto_start: true }, desiredConfig)).toBe(
+ false
+ );
+ expect(manualProxyConfigsMatch({ ...desiredConfig, port: 8080 }, desiredConfig)).toBe(false);
+ });
+});
+
+describe("shouldResetAgentProxyOwner", () => {
+ it("keeps a proxy owned by the authenticated account", () => {
+ expect(shouldResetAgentProxyOwner("user-a", "user-a", true)).toBe(false);
+ });
+
+ it("forces a reset when another account owned the proxy", () => {
+ expect(shouldResetAgentProxyOwner("user-a", "user-b", true)).toBe(true);
+ });
+
+ it("does not silently destroy ownerless manual proxy state", () => {
+ expect(shouldResetAgentProxyOwner(null, "user-a", true)).toBe(false);
+ });
+
+ it("allows a clean ownerless proxy to be initialized without another reset", () => {
+ expect(shouldResetAgentProxyOwner(null, "user-a", false)).toBe(false);
+ });
+});
+
+describe("shouldBlockOnOwnerlessProxy", () => {
+ it("blocks an unverified saved manual credential", () => {
+ expect(shouldBlockOnOwnerlessProxy(null, null, true)).toBe(true);
+ });
+
+ it("recognizes a locally tracked Agent credential after an interrupted setup", () => {
+ expect(shouldBlockOnOwnerlessProxy(null, "user-a", true)).toBe(false);
+ });
+
+ it("does not block a clean proxy config", () => {
+ expect(shouldBlockOnOwnerlessProxy(null, null, false)).toBe(false);
+ });
+});
+
+describe("Agent proxy key registry", () => {
+ it("tracks the exact locally created key as active", () => {
+ const registry = addAgentProxyKeyRecord(
+ { keys: [] },
+ { userId: "user-a", name: "maple-agent-a" }
+ );
+
+ expect(registry).toEqual({
+ keys: [{ userId: "user-a", name: "maple-agent-a" }],
+ activeName: "maple-agent-a"
+ });
+ });
+
+ it("removes only the exact revoked key and preserves other devices/accounts", () => {
+ const registry: AgentProxyKeyRegistry = {
+ keys: [
+ { userId: "user-a", name: "maple-agent-local" },
+ { userId: "user-a", name: "maple-agent-other-device" },
+ { userId: "user-b", name: "maple-agent-user-b" }
+ ],
+ activeName: "maple-agent-local"
+ };
+
+ expect(removeAgentProxyKeyRecord(registry, "maple-agent-local")).toEqual({
+ keys: [
+ { userId: "user-a", name: "maple-agent-other-device" },
+ { userId: "user-b", name: "maple-agent-user-b" }
+ ],
+ activeName: undefined
+ });
+ });
+
+ it("detaches a manual proxy config without forgetting the tracked key", () => {
+ const registry: AgentProxyKeyRegistry = {
+ keys: [{ userId: "user-a", name: "maple-agent-local" }],
+ activeName: "maple-agent-local"
+ };
+
+ expect(deactivateAgentProxyKeyRegistry(registry)).toEqual({
+ keys: [{ userId: "user-a", name: "maple-agent-local" }],
+ activeName: undefined
+ });
+ });
+});
diff --git a/frontend/src/services/proxyService.ts b/frontend/src/services/proxyService.ts
index 538c891f..22dd7e2e 100644
--- a/frontend/src/services/proxyService.ts
+++ b/frontend/src/services/proxyService.ts
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
+import { isTauriDesktop } from "@/utils/platform";
export interface ProxyConfig {
host: string;
@@ -16,13 +17,128 @@ export interface ProxyStatus {
error?: string;
}
+export type CreateProxyApiKey = (name: string) => Promise;
+export type DeleteProxyApiKey = (name: string) => Promise;
+
+export class AgentProxyManualConfigConflictError extends Error {
+ constructor() {
+ super(
+ "A saved Local OpenAI Proxy credential must be explicitly replaced before Agent Mode can use this proxy"
+ );
+ this.name = "AgentProxyManualConfigConflictError";
+ }
+}
+
+export class AgentProxyReplacementSetupError extends Error {
+ constructor(cause: unknown) {
+ super(
+ `The saved local proxy setup was replaced, but Agent Mode could not finish configuring its proxy: ${errorMessage(cause)}`
+ );
+ this.name = "AgentProxyReplacementSetupError";
+ }
+}
+
+export interface AgentProxyKeyRecord {
+ userId: string;
+ name: string;
+}
+
+export interface AgentProxyKeyRegistry {
+ keys: AgentProxyKeyRecord[];
+ activeName?: string;
+}
+
+const AGENT_PROXY_OWNER_KEY = "maple-agent-proxy-owner-v1";
+const AGENT_PROXY_KEY_REGISTRY_KEY = "maple-agent-proxy-keys-v1";
+const MAX_PROXY_RECONCILE_ATTEMPTS = 3;
+
+export function shouldResetAgentProxyOwner(
+ storedOwner: string | null,
+ userId: string,
+ hasExistingProxyState: boolean
+): boolean {
+ return hasExistingProxyState && storedOwner !== null && storedOwner !== userId;
+}
+
+export function shouldBlockOnOwnerlessProxy(
+ storedOwner: string | null,
+ trackedOwner: string | null,
+ hasExistingProxyState: boolean
+): boolean {
+ return hasExistingProxyState && storedOwner === null && trackedOwner === null;
+}
+
+export function addAgentProxyKeyRecord(
+ registry: AgentProxyKeyRegistry,
+ record: AgentProxyKeyRecord
+): AgentProxyKeyRegistry {
+ return {
+ keys: [...registry.keys.filter((candidate) => candidate.name !== record.name), record],
+ activeName: record.name
+ };
+}
+
+export function removeAgentProxyKeyRecord(
+ registry: AgentProxyKeyRegistry,
+ name: string
+): AgentProxyKeyRegistry {
+ return {
+ keys: registry.keys.filter((candidate) => candidate.name !== name),
+ activeName: registry.activeName === name ? undefined : registry.activeName
+ };
+}
+
+export function deactivateAgentProxyKeyRegistry(
+ registry: AgentProxyKeyRegistry
+): AgentProxyKeyRegistry {
+ return { ...registry, activeName: undefined };
+}
+
+export function agentProxyConfigsMatch(active: ProxyConfig, desired: ProxyConfig): boolean {
+ return (
+ active.host.trim().toLowerCase() === desired.host.trim().toLowerCase() &&
+ active.port === desired.port &&
+ active.api_key.trim() === desired.api_key.trim() &&
+ active.enabled === desired.enabled &&
+ (active.enable_cors ?? true) === (desired.enable_cors ?? true) &&
+ normalizeBackendUrl(active.backend_url) === normalizeBackendUrl(desired.backend_url)
+ );
+}
+
+export function manualProxyConfigsMatch(active: ProxyConfig, desired: ProxyConfig): boolean {
+ return (
+ agentProxyConfigsMatch(active, desired) &&
+ (active.auto_start ?? false) === (desired.auto_start ?? false)
+ );
+}
+
+export function enforceAgentProxySecurity(config: ProxyConfig): ProxyConfig {
+ return {
+ ...config,
+ // Agent credentials are account-backed and the local proxy has no inbound
+ // client authentication. Never inherit a manual LAN bind.
+ host: "127.0.0.1",
+ // Authentication/owner reconciliation happens after app startup. An Agent
+ // credential must never start before that boundary runs.
+ auto_start: false
+ };
+}
+
class ProxyService {
+ private ensureReadyTail: Promise = Promise.resolve();
+
private validatePort(port: number): void {
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`Port must be a valid u16 integer (0-65535), got: ${port}`);
}
}
+ private validateAgentPort(port: number): void {
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error(`Port must be a valid TCP port (1-65535), got: ${port}`);
+ }
+ }
+
async startProxy(config: ProxyConfig): Promise {
try {
this.validatePort(config.port);
@@ -76,61 +192,496 @@ class ProxyService {
}
}
- async testProxyPort(host: string, port: number): Promise {
+ async ensureProxyReady(
+ userId: string,
+ createApiKey: CreateProxyApiKey,
+ deleteApiKey: DeleteProxyApiKey
+ ): Promise {
+ if (!userId.trim()) throw new Error("Agent proxy setup requires an authenticated user");
+
+ return await this.enqueueProxyOperation(async () => {
+ return await this.ensureProxyReadyInner(userId, createApiKey, deleteApiKey);
+ });
+ }
+
+ private async ensureProxyReadyInner(
+ userId: string,
+ createApiKey: CreateProxyApiKey,
+ deleteApiKey: DeleteProxyApiKey
+ ): Promise {
+ let status = await this.getProxyStatus();
+ // Saved settings are the source of truth even while a process is running:
+ // they may come from a different managed workspace or have been changed
+ // since that process started.
+ let savedConfig = await this.loadAgentProxyConfig();
+ const storedOwner = this.loadAgentProxyOwner();
+ const activeTrackedKey = this.loadActiveTrackedKey();
+ const trackedOwner = savedConfig.api_key.trim() ? activeTrackedKey?.userId || null : null;
+ const hasExistingProxyState = status.running || Boolean(savedConfig.api_key.trim());
+ if (shouldBlockOnOwnerlessProxy(storedOwner, trackedOwner, hasExistingProxyState)) {
+ throw new AgentProxyManualConfigConflictError();
+ }
+
+ if (shouldResetAgentProxyOwner(storedOwner ?? trackedOwner, userId, hasExistingProxyState)) {
+ await this.resetProxyLocalState();
+ status = await this.getProxyStatus();
+ savedConfig = await this.loadAgentProxyConfig();
+ }
+
+ const reusableTrackedKey =
+ savedConfig.api_key.trim() && activeTrackedKey?.userId === userId
+ ? activeTrackedKey.name
+ : undefined;
+ await this.revokeTrackedAgentProxyKeys(userId, deleteApiKey, reusableTrackedKey);
+
+ let apiKey = savedConfig.api_key.trim();
+ let newlyCreatedKeyName: string | null = null;
+
+ if (!apiKey) {
+ let created: { key: string; name: string };
+ try {
+ created = await this.createTrackedAgentProxyKey(userId, createApiKey, deleteApiKey);
+ } catch (error) {
+ await this.resetProxyLocalState();
+ throw error;
+ }
+ apiKey = created.key;
+ newlyCreatedKeyName = created.name;
+ }
+
+ let nextConfig: ProxyConfig;
+ let readyStatus: ProxyStatus;
try {
- this.validatePort(port);
- return await invoke("test_proxy_port", { host, port });
+ nextConfig = this.buildAgentProxyConfig(savedConfig, apiKey);
+ if (!status.running && (!savedConfig.api_key.trim() || savedConfig.auto_start !== false)) {
+ await this.saveProxySettings({ ...nextConfig, enabled: false });
+ } else if (savedConfig.auto_start !== false) {
+ // A previously manual proxy may have auto-started with this credential.
+ // Persist the Agent-safe preference even when the already-running
+ // process otherwise matches and does not need a restart.
+ await this.saveProxySettings(nextConfig);
+ }
+ readyStatus = await this.reconcileRunningProxy(status, nextConfig);
} catch (error) {
- console.error("Failed to test proxy port:", error);
+ if (newlyCreatedKeyName) {
+ await this.revokeTrackedAgentProxyKey(newlyCreatedKeyName, deleteApiKey);
+ await this.resetProxyLocalState();
+ }
throw error;
}
+
+ if ((await this.checkProxyBackendAuth(readyStatus)) === "auth_error") {
+ const trackedKey = this.loadActiveTrackedKey();
+ if (trackedKey?.userId === userId) {
+ await this.revokeTrackedAgentProxyKey(trackedKey.name, deleteApiKey);
+ }
+
+ let created: { key: string; name: string };
+ try {
+ created = await this.createTrackedAgentProxyKey(userId, createApiKey, deleteApiKey);
+ } catch (error) {
+ await this.resetProxyLocalState();
+ throw error;
+ }
+ apiKey = created.key;
+ newlyCreatedKeyName = created.name;
+ nextConfig = this.buildAgentProxyConfig(readyStatus.config, apiKey);
+ try {
+ readyStatus = await this.reconcileRunningProxy(readyStatus, nextConfig);
+ } catch (error) {
+ await this.revokeTrackedAgentProxyKey(created.name, deleteApiKey);
+ await this.resetProxyLocalState();
+ throw error;
+ }
+
+ if ((await this.checkProxyBackendAuth(readyStatus)) === "auth_error") {
+ await this.revokeTrackedAgentProxyKey(created.name, deleteApiKey);
+ await this.resetProxyLocalState();
+ throw new Error("Maple proxy API key was refreshed but the backend still returned 401");
+ }
+ }
+
+ this.saveAgentProxyOwner(userId);
+ return readyStatus;
}
- // Stop proxy if running and reset saved config (used on logout)
- async stopAndResetProxy(): Promise {
+ private async loadAgentProxyConfig(): Promise {
try {
- // Check if proxy is running and stop it
- const status = await this.getProxyStatus();
+ return await invoke("load_proxy_config");
+ } catch (error) {
+ console.error("Failed to load Agent proxy config:", error);
+ throw error;
+ }
+ }
+
+ private async reconcileRunningProxy(
+ initialStatus: ProxyStatus,
+ desiredConfig: ProxyConfig
+ ): Promise {
+ let status = initialStatus;
+
+ for (let attempt = 0; attempt < MAX_PROXY_RECONCILE_ATTEMPTS; attempt += 1) {
+ if (status.running && agentProxyConfigsMatch(status.config, desiredConfig)) {
+ return status;
+ }
if (status.running) {
await this.stopProxy();
}
+
+ // start_proxy is idempotent. If a delayed auto-start wins this race, it
+ // can return a different running config; the next bounded iteration
+ // stops that winner and retries the desired configuration.
+ status = await this.startProxy(desiredConfig);
+ }
+
+ if (status.running && agentProxyConfigsMatch(status.config, desiredConfig)) {
+ return status;
+ }
+
+ throw new Error("Maple proxy could not be reconciled with the Agent Mode configuration");
+ }
+
+ private buildAgentProxyConfig(savedConfig: ProxyConfig, apiKey: string): ProxyConfig {
+ const backendUrl =
+ import.meta.env.VITE_OPEN_SECRET_API_URL ||
+ savedConfig.backend_url ||
+ "https://enclave.trymaple.ai";
+ const port = Number(savedConfig.port || 8080);
+ this.validateAgentPort(port);
+
+ return enforceAgentProxySecurity({
+ ...savedConfig,
+ host: savedConfig.host || "127.0.0.1",
+ port,
+ api_key: apiKey,
+ enabled: true,
+ enable_cors: savedConfig.enable_cors ?? true,
+ backend_url: backendUrl,
+ auto_start: false
+ });
+ }
+
+ private async checkProxyBackendAuth(
+ status: ProxyStatus
+ ): Promise<"ok" | "auth_error" | "unknown_error"> {
+ if (!status.running || !status.config.api_key.trim()) {
+ return "auth_error";
+ }
+
+ const host = status.config.host === "0.0.0.0" ? "127.0.0.1" : status.config.host;
+ try {
+ const response = await fetch(`http://${host}:${status.config.port}/v1/models`);
+ if (response.ok) return "ok";
+
+ const body = await response.text();
+ if (
+ response.status === 401 ||
+ body.includes('"status":401') ||
+ body.toLowerCase().includes("unauthorized")
+ ) {
+ return "auth_error";
+ }
+
+ return "unknown_error";
} catch {
- // Proxy may not be running, that's fine
+ return "unknown_error";
+ }
+ }
+
+ async testProxyPort(host: string, port: number): Promise {
+ try {
+ this.validatePort(port);
+ return await invoke("test_proxy_port", { host, port });
+ } catch (error) {
+ console.error("Failed to test proxy port:", error);
+ throw error;
}
+ }
+
+ async startManualProxy(config: ProxyConfig): Promise {
+ return await this.enqueueProxyOperation(async () => {
+ const status = await this.startProxy(config);
+ if (!status.running || !manualProxyConfigsMatch(status.config, config)) {
+ throw new Error(
+ "The local proxy changed while the manual setup was starting. Review the current settings and try again."
+ );
+ }
+ // Do not discard the previous Agent ownership association until the
+ // native mutation has actually succeeded.
+ this.markCurrentProxyConfigAsManual();
+ return status;
+ });
+ }
+
+ async saveManualProxySettings(config: ProxyConfig): Promise {
+ await this.enqueueProxyOperation(async () => {
+ await this.saveProxySettings(config);
+ this.markCurrentProxyConfigAsManual();
+ });
+ }
+
+ async stopManualProxy(): Promise {
+ return await this.enqueueProxyOperation(async () => await this.stopProxy());
+ }
+
+ private markCurrentProxyConfigAsManual(): void {
+ this.clearAgentProxyOwner();
+ const registry = this.loadAgentProxyKeyRegistry();
+ if (registry.activeName) {
+ this.saveAgentProxyKeyRegistry(deactivateAgentProxyKeyRegistry(registry));
+ }
+ }
+
+ async replaceOwnerlessProxyAndEnsureReady(
+ userId: string,
+ createApiKey: CreateProxyApiKey,
+ deleteApiKey: DeleteProxyApiKey
+ ): Promise {
+ if (!userId.trim()) throw new Error("Agent proxy setup requires an authenticated user");
+
+ return await this.enqueueProxyOperation(async () => {
+ const [status, config] = await Promise.all([
+ this.getProxyStatus(),
+ this.loadAgentProxyConfig()
+ ]);
+ const storedOwner = this.loadAgentProxyOwner();
+ const trackedOwner = config.api_key.trim()
+ ? this.loadActiveTrackedKey()?.userId || null
+ : null;
+ const hasExistingProxyState = status.running || Boolean(config.api_key.trim());
+
+ if (!shouldBlockOnOwnerlessProxy(storedOwner, trackedOwner, hasExistingProxyState)) {
+ throw new Error("The saved proxy credential changed before it could be replaced");
+ }
+
+ // This destructive reset is reached only from the explicit replacement
+ // action in Agent Mode. The unverified backend key is deliberately not
+ // revoked because it may belong to another account.
+ await this.resetProxyLocalState();
+ try {
+ return await this.ensureProxyReadyInner(userId, createApiKey, deleteApiKey);
+ } catch (error) {
+ // The destructive user-approved replacement has already happened.
+ // Tell the UI to leave conflict mode so ordinary Agent setup can be
+ // retried instead of presenting a dead replacement button.
+ throw new AgentProxyReplacementSetupError(error);
+ }
+ });
+ }
+
+ // Stop and scrub local credentials first so an offline backend can never
+ // prevent logout. Exact locally-created key records remain queued in local
+ // metadata when remote revocation fails and are retried when that account
+ // next initializes Agent Mode.
+ async stopAndResetProxy(userId?: string | null, deleteApiKey?: DeleteProxyApiKey): Promise {
+ if (!isTauriDesktop()) return;
+
+ await this.enqueueProxyOperation(async () => {
+ await this.resetProxyLocalState();
+ if (userId && deleteApiKey) {
+ // Start the authenticated cleanup while the caller still owns its SDK
+ // session, but do not await an unbounded encrypted fetch. Successful
+ // deletions remove their exact records; failures/timeouts leave records
+ // available for the account's next initialization retry.
+ void this.revokeTrackedAgentProxyKeysBestEffort(userId, deleteApiKey).catch(() => {});
+ }
+ });
+ }
+
+ private async enqueueProxyOperation(operation: () => Promise): Promise {
+ const queued = this.ensureReadyTail.then(operation);
+ this.ensureReadyTail = queued.then(
+ () => undefined,
+ () => undefined
+ );
+ return await queued;
+ }
+
+ private async resetProxyLocalState(): Promise {
+ if (!isTauriDesktop()) return;
try {
- // Save default config to clear auto_start and API key
- await this.saveProxySettings({
- host: "127.0.0.1",
- port: 8080,
- api_key: "",
- enabled: false,
- enable_cors: true,
- auto_start: false
- });
+ await invoke("stop_and_reset_proxy");
} catch (error) {
- console.error("Failed to reset proxy config:", error);
+ console.error("Failed to stop and reset proxy:", error);
+ throw error;
+ }
+
+ // These values contain only ownership/key-name metadata. The native
+ // config/keyring scrub above is the credential boundary, so a WebView
+ // storage failure here must not report that logout itself failed.
+ try {
+ this.clearAgentProxyOwner();
+ this.clearActiveTrackedKey();
+ } catch {
+ // A stale association is conservative: the next Agent initialization
+ // will reconcile or explicitly reset it, while no credential remains.
}
}
- // Helper to check if we're in Tauri desktop environment
- async isTauriDesktop(): Promise {
+ private loadAgentProxyOwner(): string | null {
+ if (typeof localStorage === "undefined") return null;
+ return localStorage.getItem(AGENT_PROXY_OWNER_KEY);
+ }
+
+ private saveAgentProxyOwner(userId: string): void {
+ if (typeof localStorage === "undefined") {
+ throw new Error("Local storage is unavailable for Agent proxy ownership");
+ }
+ localStorage.setItem(AGENT_PROXY_OWNER_KEY, userId);
+ }
+
+ private clearAgentProxyOwner(): void {
+ if (typeof localStorage === "undefined") return;
+ localStorage.removeItem(AGENT_PROXY_OWNER_KEY);
+ }
+
+ private async createTrackedAgentProxyKey(
+ userId: string,
+ createApiKey: CreateProxyApiKey,
+ deleteApiKey: DeleteProxyApiKey
+ ): Promise<{ key: string; name: string }> {
+ const name = createAgentProxyKeyName();
+ const key = await createApiKey(name);
+
+ try {
+ const registry = addAgentProxyKeyRecord(this.loadAgentProxyKeyRegistry(), { userId, name });
+ this.saveAgentProxyKeyRegistry(registry);
+ } catch (trackingError) {
+ try {
+ await deleteApiKey(name);
+ } catch (revokeError) {
+ throw new Error(
+ `Created an Agent proxy key but could not track or revoke it. Tracking failed: ${errorMessage(trackingError)}. Revocation failed: ${errorMessage(revokeError)}`
+ );
+ }
+ throw trackingError;
+ }
+
+ return { key, name };
+ }
+
+ private async revokeTrackedAgentProxyKeys(
+ userId: string,
+ deleteApiKey: DeleteProxyApiKey,
+ keepName?: string
+ ): Promise {
+ const records = this.loadAgentProxyKeyRegistry().keys.filter(
+ (record) => record.userId === userId && record.name !== keepName
+ );
+ for (const record of records) {
+ await this.revokeTrackedAgentProxyKey(record.name, deleteApiKey);
+ }
+ }
+
+ private async revokeTrackedAgentProxyKeysBestEffort(
+ userId: string,
+ deleteApiKey: DeleteProxyApiKey
+ ): Promise {
+ const records = this.loadAgentProxyKeyRegistry().keys.filter(
+ (record) => record.userId === userId
+ );
+ for (const record of records) {
+ try {
+ await this.revokeTrackedAgentProxyKey(record.name, deleteApiKey);
+ } catch {
+ // Keep this exact record for retry, but continue so one backend/network
+ // failure does not prevent revocation of the account's other keys.
+ }
+ }
+ }
+
+ private async revokeTrackedAgentProxyKey(
+ name: string,
+ deleteApiKey: DeleteProxyApiKey
+ ): Promise {
try {
- const { isTauri } = await import("@tauri-apps/api/core");
- const inTauri = await isTauri();
+ await deleteApiKey(name);
+ } catch (error) {
+ if (!isMissingApiKeyError(error)) throw error;
+ }
+
+ const registry = removeAgentProxyKeyRecord(this.loadAgentProxyKeyRegistry(), name);
+ this.saveAgentProxyKeyRegistry(registry);
+ }
- if (!inTauri) return false;
+ private loadActiveTrackedKey(): AgentProxyKeyRecord | null {
+ const registry = this.loadAgentProxyKeyRegistry();
+ if (!registry.activeName) return null;
+ return registry.keys.find((record) => record.name === registry.activeName) || null;
+ }
- // Check if it's desktop (not mobile)
- const { type } = await import("@tauri-apps/plugin-os");
- const platform = await type();
+ private clearActiveTrackedKey(): void {
+ const registry = this.loadAgentProxyKeyRegistry();
+ if (!registry.activeName) return;
+ this.saveAgentProxyKeyRegistry(deactivateAgentProxyKeyRegistry(registry));
+ }
- // Desktop platforms
- return platform === "macos" || platform === "windows" || platform === "linux";
+ private loadAgentProxyKeyRegistry(): AgentProxyKeyRegistry {
+ if (typeof localStorage === "undefined") return { keys: [] };
+ const stored = localStorage.getItem(AGENT_PROXY_KEY_REGISTRY_KEY);
+ if (!stored) return { keys: [] };
+
+ let parsed: Partial;
+ try {
+ parsed = JSON.parse(stored) as Partial;
} catch {
- return false;
+ localStorage.removeItem(AGENT_PROXY_KEY_REGISTRY_KEY);
+ return { keys: [] };
+ }
+ if (!Array.isArray(parsed.keys)) {
+ localStorage.removeItem(AGENT_PROXY_KEY_REGISTRY_KEY);
+ return { keys: [] };
}
+ const keys = parsed.keys.filter((record): record is AgentProxyKeyRecord =>
+ Boolean(
+ record &&
+ typeof record === "object" &&
+ typeof record.userId === "string" &&
+ record.userId.trim() &&
+ typeof record.name === "string" &&
+ record.name.trim()
+ )
+ );
+ const activeName =
+ typeof parsed.activeName === "string" &&
+ keys.some((record) => record.name === parsed.activeName)
+ ? parsed.activeName
+ : undefined;
+ return { keys, activeName };
}
+
+ private saveAgentProxyKeyRegistry(registry: AgentProxyKeyRegistry): void {
+ if (typeof localStorage === "undefined") {
+ throw new Error("Local storage is unavailable for Agent proxy key tracking");
+ }
+ localStorage.setItem(AGENT_PROXY_KEY_REGISTRY_KEY, JSON.stringify(registry));
+ }
+}
+
+function createAgentProxyKeyName(): string {
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
+ const random =
+ typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID().slice(0, 8)
+ : Math.random().toString(36).slice(2, 10);
+ return `maple-agent-${date}-${random}`;
+}
+
+function normalizeBackendUrl(value?: string): string {
+ return (value || "").trim().replace(/\/+$/, "");
+}
+
+function isMissingApiKeyError(error: unknown): boolean {
+ if (error && typeof error === "object" && "status" in error && error.status === 404) {
+ return true;
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ return /\b404\b|not found/i.test(message);
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
}
export const proxyService = new ProxyService();
From 550c5ce2abb463b2eefd86186005b9d25ca2f8f6 Mon Sep 17 00:00:00 2001
From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Date: Fri, 10 Jul 2026 23:24:35 +0000
Subject: [PATCH 2/5] Add client-side feature flags
---
frontend/.env.example | 1 +
frontend/src/services/flags.test.ts | 129 ++++++++++++++++++++++
frontend/src/services/flags.ts | 164 ++++++++++++++++++++++++++++
frontend/src/vite-env.d.ts | 1 +
scripts/ci/_common.sh | 2 +
5 files changed, 297 insertions(+)
create mode 100644 frontend/src/services/flags.test.ts
create mode 100644 frontend/src/services/flags.ts
diff --git a/frontend/.env.example b/frontend/.env.example
index 8dbf59d4..043d54ce 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,5 +1,6 @@
# Public OpenSecret project id. Optional; the app uses this value by default.
VITE_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6
VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000
+#VITE_OS_FLAGS_BASE_URL=https://flags-dev.opensecret.cloud
#VITE_MAPLE_BILLING_API_URL=http://127.0.0.1:3001
#VITE_DEV_MODEL_OVERRIDE=gpt-4o
diff --git a/frontend/src/services/flags.test.ts b/frontend/src/services/flags.test.ts
new file mode 100644
index 00000000..3e05ebdd
--- /dev/null
+++ b/frontend/src/services/flags.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, test } from "bun:test";
+import { FlagsClient, type FlagsFetch } from "./flags";
+
+const USER_A = "00000000-0000-0000-0000-000000000001";
+const USER_B = "00000000-0000-0000-0000-000000000002";
+
+function jsonResponse(userId: string, flags: Record, status = 200): Response {
+ return new Response(JSON.stringify({ user_uuid: userId, flags }), {
+ status,
+ headers: { "content-type": "application/json" }
+ });
+}
+
+function client(fetchFn: FlagsFetch, options: { now?: () => number; cacheTtlMs?: number } = {}) {
+ return new FlagsClient({
+ baseUrl: "https://flags.example.test/base",
+ requestTimeoutMs: 1_000,
+ fetchFn,
+ ...options
+ });
+}
+
+describe("FlagsClient", () => {
+ test("uses the public endpoint with normalized keys and no credentials", async () => {
+ let requestUrl: URL | undefined;
+ let requestInit: RequestInit | undefined;
+ const flags = client(async (input, init) => {
+ requestUrl = new URL(input.toString());
+ requestInit = init;
+ return jsonResponse(USER_A, { alpha: true, beta: false });
+ });
+
+ await expect(flags.getFlags(USER_A, [" beta ", "alpha", "beta"])).resolves.toEqual({
+ alpha: true,
+ beta: false
+ });
+ expect(requestUrl?.pathname).toBe(`/base/v1/users/${USER_A}/flags`);
+ expect(requestUrl?.searchParams.get("keys")).toBe("alpha,beta");
+ expect(requestInit?.credentials).toBe("omit");
+ expect(requestInit?.cache).toBe("no-store");
+ expect(new Headers(requestInit?.headers).has("authorization")).toBe(false);
+ });
+
+ test("treats a missing flag as disabled", async () => {
+ const flags = client(async () => jsonResponse(USER_A, {}));
+ await expect(flags.isEnabled(USER_A, "missing")).resolves.toBe(false);
+ });
+
+ test("coalesces concurrent and normalized equivalent lookups", async () => {
+ let calls = 0;
+ let resolveRequest: ((response: Response) => void) | undefined;
+ const flags = client(
+ () =>
+ new Promise((resolve) => {
+ calls += 1;
+ resolveRequest = resolve;
+ })
+ );
+
+ const first = flags.getFlags(USER_A, ["beta", "alpha"]);
+ const second = flags.getFlags(USER_A, ["alpha", "beta", "alpha"]);
+ expect(first).toBe(second);
+ expect(calls).toBe(1);
+
+ resolveRequest?.(jsonResponse(USER_A, { alpha: true, beta: false }));
+ await expect(Promise.all([first, second])).resolves.toHaveLength(2);
+ expect(calls).toBe(1);
+ });
+
+ test("caches successful responses for the configured TTL", async () => {
+ let now = 1_000;
+ let calls = 0;
+ const flags = client(
+ async () => {
+ calls += 1;
+ return jsonResponse(USER_A, { enabled: calls === 1 });
+ },
+ { now: () => now, cacheTtlMs: 600_000 }
+ );
+
+ await expect(flags.isEnabled(USER_A, "enabled")).resolves.toBe(true);
+ now = 600_999;
+ await expect(flags.isEnabled(USER_A, "enabled")).resolves.toBe(true);
+ expect(calls).toBe(1);
+
+ now = 601_000;
+ await expect(flags.isEnabled(USER_A, "enabled")).resolves.toBe(false);
+ expect(calls).toBe(2);
+ });
+
+ test("isolates cache entries by user", async () => {
+ let calls = 0;
+ const flags = client(async (input) => {
+ calls += 1;
+ const userId = new URL(input.toString()).pathname.includes(USER_A) ? USER_A : USER_B;
+ return jsonResponse(userId, { enabled: userId === USER_A });
+ });
+
+ await expect(flags.isEnabled(USER_A, "enabled")).resolves.toBe(true);
+ await expect(flags.isEnabled(USER_B, "enabled")).resolves.toBe(false);
+ expect(calls).toBe(2);
+ });
+
+ test("does not cache HTTP failures", async () => {
+ let calls = 0;
+ const flags = client(async () => {
+ calls += 1;
+ if (calls === 1) return jsonResponse(USER_A, {}, 503);
+ return jsonResponse(USER_A, { enabled: true });
+ });
+
+ await expect(flags.isEnabled(USER_A, "enabled")).rejects.toThrow("status 503");
+ await expect(flags.isEnabled(USER_A, "enabled")).resolves.toBe(true);
+ expect(calls).toBe(2);
+ });
+
+ test("rejects invalid responses without caching them", async () => {
+ let calls = 0;
+ const flags = client(async () => {
+ calls += 1;
+ if (calls === 1) return jsonResponse(USER_A, { enabled: "yes" });
+ return jsonResponse(USER_A, { enabled: true });
+ });
+
+ await expect(flags.isEnabled(USER_A, "enabled")).rejects.toThrow("non-boolean");
+ await expect(flags.isEnabled(USER_A, "enabled")).resolves.toBe(true);
+ expect(calls).toBe(2);
+ });
+});
diff --git a/frontend/src/services/flags.ts b/frontend/src/services/flags.ts
new file mode 100644
index 00000000..550efb92
--- /dev/null
+++ b/frontend/src/services/flags.ts
@@ -0,0 +1,164 @@
+const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000;
+const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 1000;
+const DEV_FLAGS_BASE_URL = "https://flags-dev.opensecret.cloud";
+const PROD_FLAGS_BASE_URL = "https://flags.opensecret.cloud";
+
+export type FlagValues = Readonly>;
+
+export type FlagsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise;
+
+export interface FlagsClientOptions {
+ baseUrl?: string;
+ cacheTtlMs?: number;
+ requestTimeoutMs?: number;
+ fetchFn?: FlagsFetch;
+ now?: () => number;
+}
+
+interface CacheEntry {
+ expiresAt: number;
+ settled: boolean;
+ promise: Promise;
+ token: object;
+}
+
+function defaultBaseUrl(): string {
+ const configured = import.meta.env.VITE_OS_FLAGS_BASE_URL?.trim();
+ if (configured) return configured;
+ return import.meta.env.PROD ? PROD_FLAGS_BASE_URL : DEV_FLAGS_BASE_URL;
+}
+
+function normalizeBaseUrl(value: string): URL {
+ const url = new URL(value);
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error("Feature flag base URL must use HTTP or HTTPS");
+ }
+ url.search = "";
+ url.hash = "";
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
+ return url;
+}
+
+function normalizeKeys(keys: readonly string[]): string[] {
+ return [...new Set(keys.map((key) => key.trim()).filter(Boolean))].sort();
+}
+
+function cacheKey(userId: string, keys: readonly string[]): string {
+ return JSON.stringify([userId, keys]);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseFlagValues(value: unknown, expectedUserId: string): FlagValues {
+ if (!isRecord(value) || typeof value.user_uuid !== "string" || !isRecord(value.flags)) {
+ throw new Error("Feature flag service returned an invalid response");
+ }
+ if (value.user_uuid.toLowerCase() !== expectedUserId.toLowerCase()) {
+ throw new Error("Feature flag service returned a response for another user");
+ }
+
+ const entries: Array<[string, boolean]> = [];
+ for (const [key, enabled] of Object.entries(value.flags)) {
+ if (typeof enabled !== "boolean") {
+ throw new Error("Feature flag service returned a non-boolean flag");
+ }
+ entries.push([key, enabled]);
+ }
+ return Object.freeze(Object.fromEntries(entries));
+}
+
+export class FlagsClient {
+ private readonly baseUrl: URL;
+ private readonly cacheTtlMs: number;
+ private readonly requestTimeoutMs: number;
+ private readonly fetchFn: FlagsFetch;
+ private readonly now: () => number;
+ private readonly cache = new Map();
+
+ constructor(options: FlagsClientOptions = {}) {
+ this.baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultBaseUrl());
+ this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
+ this.fetchFn = options.fetchFn ?? ((input, init) => globalThis.fetch(input, init));
+ this.now = options.now ?? Date.now;
+
+ if (this.cacheTtlMs <= 0 || this.requestTimeoutMs <= 0) {
+ throw new Error("Feature flag cache TTL and request timeout must be positive");
+ }
+ }
+
+ getFlags(userId: string, keys: readonly string[]): Promise {
+ const normalizedUserId = userId.trim();
+ if (!normalizedUserId) {
+ return Promise.reject(new Error("Feature flag lookup requires a user ID"));
+ }
+
+ const normalizedKeys = normalizeKeys(keys);
+ if (normalizedKeys.length === 0) return Promise.resolve(Object.freeze({}));
+
+ const now = this.now();
+ this.evictExpired(now);
+ const key = cacheKey(normalizedUserId, normalizedKeys);
+ const existing = this.cache.get(key);
+ if (existing && (!existing.settled || existing.expiresAt > now)) {
+ return existing.promise;
+ }
+
+ const token = {};
+ const promise = this.requestFlags(normalizedUserId, normalizedKeys).then(
+ (flags) => {
+ const current = this.cache.get(key);
+ if (current?.token === token) {
+ current.settled = true;
+ current.expiresAt = this.now() + this.cacheTtlMs;
+ }
+ return flags;
+ },
+ (error: unknown) => {
+ if (this.cache.get(key)?.token === token) this.cache.delete(key);
+ throw error;
+ }
+ );
+ const entry = { expiresAt: Number.POSITIVE_INFINITY, settled: false, promise, token };
+ this.cache.set(key, entry);
+ return promise;
+ }
+
+ async isEnabled(userId: string, key: string): Promise {
+ const flags = await this.getFlags(userId, [key]);
+ return flags[key.trim()] === true;
+ }
+
+ private evictExpired(now: number): void {
+ for (const [key, entry] of this.cache) {
+ if (entry.settled && entry.expiresAt <= now) this.cache.delete(key);
+ }
+ }
+
+ private async requestFlags(userId: string, keys: readonly string[]): Promise {
+ const url = new URL(`v1/users/${encodeURIComponent(userId)}/flags`, this.baseUrl);
+ url.searchParams.set("keys", keys.join(","));
+
+ const controller = new AbortController();
+ const timeout = globalThis.setTimeout(() => controller.abort(), this.requestTimeoutMs);
+ try {
+ const response = await this.fetchFn(url, {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ credentials: "omit",
+ cache: "no-store",
+ signal: controller.signal
+ });
+ if (!response.ok) {
+ throw new Error(`Feature flag request failed with status ${response.status}`);
+ }
+ return parseFlagValues(await response.json(), userId);
+ } finally {
+ globalThis.clearTimeout(timeout);
+ }
+ }
+}
+
+export const flagsClient = new FlagsClient();
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index 09fed5cb..9a64df66 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_OPEN_SECRET_API_URL: string;
+ readonly VITE_OS_FLAGS_BASE_URL?: string;
readonly VITE_CLIENT_ID?: string;
readonly VITE_MAPLE_BILLING_API_URL?: string;
readonly VITE_DEV_MODEL_OVERRIDE?: string;
diff --git a/scripts/ci/_common.sh b/scripts/ci/_common.sh
index 076ffbea..1791e3f7 100755
--- a/scripts/ci/_common.sh
+++ b/scripts/ci/_common.sh
@@ -141,6 +141,7 @@ use_pr_environment() {
done < <(env)
export VITE_OPEN_SECRET_API_URL="https://enclave.secretgpt.ai"
+ export VITE_OS_FLAGS_BASE_URL="https://flags-dev.opensecret.cloud"
export VITE_MAPLE_BILLING_API_URL="https://billing-dev.opensecret.cloud"
export VITE_CLIENT_ID="ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"
}
@@ -156,6 +157,7 @@ use_release_environment() {
done < <(env)
export VITE_OPEN_SECRET_API_URL="https://enclave.trymaple.ai"
+ export VITE_OS_FLAGS_BASE_URL="https://flags.opensecret.cloud"
export VITE_MAPLE_BILLING_API_URL="https://billing.opensecret.cloud"
export VITE_CLIENT_ID="ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"
}
From 2dd2833bd8083b5c7bb456d995a2df7c198cef4a Mon Sep 17 00:00:00 2001
From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Date: Fri, 10 Jul 2026 23:25:55 +0000
Subject: [PATCH 3/5] Gate Agent Mode navigation
---
frontend/src/components/Sidebar.tsx | 29 ++++++++++++++++++++++++++++-
frontend/src/services/flags.ts | 4 ++++
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 9fc0b5c0..52efa65e 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -33,6 +33,8 @@ import {
SIDEBAR_WIDTH_CLASS
} from "@/constants/layout";
import { isTauriDesktop } from "@/utils/platform";
+import { useOpenSecret } from "@opensecret/react";
+import { FEATURE_FLAGS, flagsClient } from "@/services/flags";
export function Sidebar({
chatId,
@@ -49,6 +51,8 @@ export function Sidebar({
}) {
const router = useRouter();
const location = useLocation();
+ const os = useOpenSecret();
+ const userId = os.auth.user?.user.id;
const {
searchQuery,
setSearchQuery,
@@ -166,9 +170,32 @@ export function Sidebar({
const isMobile = useIsMobile();
const isLandscapeMobile = useIsLandscapeMobile();
const isCompactLayout = isMobile || isLandscapeMobile;
- const showAgentMode = isTauriDesktop();
+ const agentModeAvailable = isTauriDesktop();
+ const [agentModeFlag, setAgentModeFlag] = useState<{
+ userId: string;
+ enabled: boolean;
+ } | null>(null);
+ const showAgentMode =
+ agentModeAvailable && agentModeFlag?.userId === userId && agentModeFlag?.enabled === true;
const isAgentMode = mode === "agent";
+ useEffect(() => {
+ if (!agentModeAvailable || !userId) return;
+
+ let disposed = false;
+ void flagsClient.isEnabled(userId, FEATURE_FLAGS.AGENT_MODE).then(
+ (enabled) => {
+ if (!disposed) setAgentModeFlag({ userId, enabled });
+ },
+ (error: unknown) => {
+ console.warn("Unable to load optional feature flags; keeping them hidden.", error);
+ }
+ );
+ return () => {
+ disposed = true;
+ };
+ }, [agentModeAvailable, userId]);
+
// Modified click outside handler to ignore clicks in dropdowns and dialogs
// Only applies on mobile - desktop users use the toggle button
const handleClickOutside = useCallback(
diff --git a/frontend/src/services/flags.ts b/frontend/src/services/flags.ts
index 550efb92..184a66d7 100644
--- a/frontend/src/services/flags.ts
+++ b/frontend/src/services/flags.ts
@@ -3,6 +3,10 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 1000;
const DEV_FLAGS_BASE_URL = "https://flags-dev.opensecret.cloud";
const PROD_FLAGS_BASE_URL = "https://flags.opensecret.cloud";
+export const FEATURE_FLAGS = {
+ AGENT_MODE: "agent_mode"
+} as const;
+
export type FlagValues = Readonly>;
export type FlagsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise;
From 13e94e508fc244ffe4f3bda403e2ec5044fc41ea Mon Sep 17 00:00:00 2001
From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Date: Sat, 11 Jul 2026 04:13:37 +0000
Subject: [PATCH 4/5] Remove client billing product fallbacks
---
frontend/src/billing/billingService.ts | 33 +-------------------------
1 file changed, 1 insertion(+), 32 deletions(-)
diff --git a/frontend/src/billing/billingService.ts b/frontend/src/billing/billingService.ts
index 8acf3f27..909d28e0 100644
--- a/frontend/src/billing/billingService.ts
+++ b/frontend/src/billing/billingService.ts
@@ -50,35 +50,6 @@ 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;
@@ -131,9 +102,7 @@ class BillingService {
}
async getBillingStatus(): Promise {
- return this.executeWithToken(async (token) =>
- normalizeBillingStatus(await fetchBillingStatus(token))
- );
+ return this.executeWithToken((token) => fetchBillingStatus(token));
}
async getPortalUrl(): Promise {
From 4a133bddb8edccb32bfdce3b252b2b5109a4fbdd Mon Sep 17 00:00:00 2001
From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Date: Sat, 11 Jul 2026 05:10:00 +0000
Subject: [PATCH 5/5] Upsell Agent Mode for users without API access
---
frontend/src/billing/billingAccess.test.ts | 34 +++++++++++++++++++
frontend/src/billing/billingAccess.ts | 7 ++++
frontend/src/components/Sidebar.tsx | 29 ++++++++++++++--
.../src/components/UpgradePromptDialog.tsx | 24 ++++++++++---
.../components/apikeys/ApiKeyDashboard.tsx | 9 ++---
5 files changed, 90 insertions(+), 13 deletions(-)
create mode 100644 frontend/src/billing/billingAccess.test.ts
create mode 100644 frontend/src/billing/billingAccess.ts
diff --git a/frontend/src/billing/billingAccess.test.ts b/frontend/src/billing/billingAccess.test.ts
new file mode 100644
index 00000000..65576d1d
--- /dev/null
+++ b/frontend/src/billing/billingAccess.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test";
+import type { BillingStatus } from "./billingApi";
+import { hasApiAccess } from "./billingAccess";
+
+function billingStatus(productName: string): BillingStatus {
+ return {
+ is_subscribed: productName !== "Free",
+ stripe_customer_id: null,
+ product_id: "test-product",
+ product_name: productName,
+ subscription_status: "active",
+ current_period_end: null,
+ can_chat: true,
+ chats_remaining: null,
+ payment_provider: "stripe",
+ total_tokens: null,
+ used_tokens: null,
+ usage_reset_date: null
+ };
+}
+
+describe("hasApiAccess", () => {
+ test.each(["Pro", "Max", "Team"])("allows the %s plan", (productName) => {
+ expect(hasApiAccess(billingStatus(productName))).toBe(true);
+ });
+
+ test.each([null, undefined])("fails closed when billing is %s", (status) => {
+ expect(hasApiAccess(status)).toBe(false);
+ });
+
+ test.each(["Free", "Starter"])("does not allow the %s plan", (productName) => {
+ expect(hasApiAccess(billingStatus(productName))).toBe(false);
+ });
+});
diff --git a/frontend/src/billing/billingAccess.ts b/frontend/src/billing/billingAccess.ts
new file mode 100644
index 00000000..a9ed7470
--- /dev/null
+++ b/frontend/src/billing/billingAccess.ts
@@ -0,0 +1,7 @@
+import type { BillingStatus } from "./billingApi";
+
+export function hasApiAccess(billingStatus: BillingStatus | null | undefined): boolean {
+ const productName = billingStatus?.product_name?.toLowerCase() ?? "";
+
+ return productName.includes("pro") || productName.includes("max") || productName.includes("team");
+}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 52efa65e..36b31215 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -35,6 +35,8 @@ import {
import { isTauriDesktop } from "@/utils/platform";
import { useOpenSecret } from "@opensecret/react";
import { FEATURE_FLAGS, flagsClient } from "@/services/flags";
+import { UpgradePromptDialog } from "@/components/UpgradePromptDialog";
+import { hasApiAccess } from "@/billing/billingAccess";
export function Sidebar({
chatId,
@@ -59,13 +61,15 @@ export function Sidebar({
isSearchVisible,
setIsSearchVisible,
selectedProjectId,
- setSelectedProjectId
+ setSelectedProjectId,
+ billingStatus
} = useLocalState();
const searchInputRef = useRef(null);
// Multi-select state
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [agentModeUpgradeOpen, setAgentModeUpgradeOpen] = useState(false);
// Enter selection mode when items are selected (e.g., via long press)
useEffect(() => {
@@ -130,12 +134,23 @@ export function Sidebar({
}
async function toggleAgentMode() {
+ const isLeavingAgentMode = location.pathname === "/agent";
+
+ if (!isLeavingAgentMode) {
+ if (billingStatus === null) return;
+
+ if (!hasApiAccess(billingStatus)) {
+ setAgentModeUpgradeOpen(true);
+ return;
+ }
+ }
+
if (isOpen) {
onToggle();
}
try {
- await router.navigate({ to: location.pathname === "/agent" ? "/" : "/agent" });
+ await router.navigate({ to: isLeavingAgentMode ? "/" : "/agent" });
} catch (error) {
console.error("Navigation failed:", error);
}
@@ -176,7 +191,10 @@ export function Sidebar({
enabled: boolean;
} | null>(null);
const showAgentMode =
- agentModeAvailable && agentModeFlag?.userId === userId && agentModeFlag?.enabled === true;
+ agentModeAvailable &&
+ billingStatus !== null &&
+ agentModeFlag?.userId === userId &&
+ agentModeFlag?.enabled === true;
const isAgentMode = mode === "agent";
useEffect(() => {
@@ -419,6 +437,11 @@ export function Sidebar({
+
);
}
diff --git a/frontend/src/components/UpgradePromptDialog.tsx b/frontend/src/components/UpgradePromptDialog.tsx
index 4fccaa8e..ef27340e 100644
--- a/frontend/src/components/UpgradePromptDialog.tsx
+++ b/frontend/src/components/UpgradePromptDialog.tsx
@@ -16,15 +16,17 @@ import {
FileText,
Gauge,
MessageCircle,
- Coins
+ Coins,
+ Bot
} from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useLocalState } from "@/state/useLocalState";
+import { hasApiAccess } from "@/billing/billingAccess";
interface UpgradePromptDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- feature: "image" | "voice" | "model" | "document" | "usage" | "tokens";
+ feature: "image" | "voice" | "model" | "document" | "usage" | "tokens" | "agent";
modelName?: string;
}
@@ -62,7 +64,7 @@ export function UpgradePromptDialog({
const isFreeTier = !localState.billingStatus?.product_name || currentPlan === "free";
const isPro = currentPlan.includes("pro") && !currentPlan.includes("max");
const isMax = currentPlan.includes("max");
- const hasApiAccess = isPro || isMax || currentPlan.includes("team");
+ const userHasApiAccess = hasApiAccess(localState.billingStatus);
const getNextPlan = () => {
if (isFreeTier) return "Pro";
@@ -162,6 +164,20 @@ export function UpgradePromptDialog({
"Auto-compaction keeps conversations flowing"
]
};
+ } else if (feature === "agent") {
+ return {
+ icon: ,
+ title: "Agent Mode",
+ description: "Use Maple as a coding agent for multi-step work across local projects",
+ requiredPlan: "Pro",
+ benefits: [
+ "Work through multi-step coding tasks",
+ "Read and edit files in projects you choose",
+ "Run commands and development tools",
+ "Keep separate agent sessions for each project",
+ "Use supported private AI models with API access"
+ ]
+ };
} else {
return {
icon: ,
@@ -230,7 +246,7 @@ export function UpgradePromptDialog({
)}
{/* Show Buy Credits button for paid users hitting usage limits */}
- {feature === "usage" && hasApiAccess && (
+ {feature === "usage" && userHasApiAccess && (
Buy Extra Credits
diff --git a/frontend/src/components/apikeys/ApiKeyDashboard.tsx b/frontend/src/components/apikeys/ApiKeyDashboard.tsx
index 590e7c33..22a29071 100644
--- a/frontend/src/components/apikeys/ApiKeyDashboard.tsx
+++ b/frontend/src/components/apikeys/ApiKeyDashboard.tsx
@@ -23,6 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocalState } from "@/state/useLocalState";
import { useNavigate } from "@tanstack/react-router";
import { isTauriDesktop } from "@/utils/platform";
+import { hasApiAccess } from "@/billing/billingAccess";
interface ApiKey {
name: string;
@@ -42,11 +43,7 @@ export function ApiKeyDashboard({ showCreditSuccessMessage = false }: ApiKeyDash
// Check if user has API access (Pro, Team, or Max plans only - not Starter)
const isBillingLoading = billingStatus === null;
- const productName = billingStatus?.product_name || "";
- const isPro = productName.toLowerCase().includes("pro");
- const isMax = productName.toLowerCase().includes("max");
- const isTeamPlan = productName.toLowerCase().includes("team");
- const hasApiAccess = isPro || isMax || isTeamPlan;
+ const userHasApiAccess = hasApiAccess(billingStatus);
// Fetch API keys
const {
@@ -117,7 +114,7 @@ export function ApiKeyDashboard({ showCreditSuccessMessage = false }: ApiKeyDash
}
// Show upgrade prompt for users without API access (Free and Starter plans)
- if (!hasApiAccess) {
+ if (!userHasApiAccess) {
return (
<>