refactor(acp): route goose2 through goose serve (#101)

This commit is contained in:
Bradley Axen
2026-04-04 10:37:51 -07:00
committed by GitHub
parent 3b156b619e
commit bc3f2f5f5d
21 changed files with 2262 additions and 90 deletions
+4
View File
@@ -13,6 +13,10 @@ Goose2 is a Tauri 2 + React 19 desktop app.
`just clean` removes Rust build artifacts, `dist`, and `node_modules`. Run `just setup` again before `just dev`.
`just setup` bootstraps a shared managed goose checkout in a home-level cache directory when it does not exist, fast-forwards it, builds a local `goose` binary, and stamps the exact branch/commit it used. `just dev` only does a lightweight preflight against that shared stamp; if the managed checkout is missing, stale, or built from the wrong branch, it warns and tells you to rerun `just setup`. By default the helper uses `~/Library/Caches/goose2-dev` on macOS, or `$XDG_CACHE_HOME/goose2-dev` / `~/.cache/goose2-dev` elsewhere. It prefers `origin/baxen/goose2` and falls back to `origin/main` when that branch does not exist yet.
Override the shared cache root or branch with `GOOSE_DEV_ROOT=/path/to/cache` and `GOOSE_DEV_BRANCH=my/integration-branch`. You can also override the checkout path directly with `GOOSE_DEV_REPO=/path/to/goose`, or the clone source with `GOOSE_DEV_CLONE_URL=...`.
Run `just` to list available commands, or see [justfile](./justfile) for the full recipe definitions.
## Important Files
+17
View File
@@ -4,9 +4,14 @@ default:
# ── Dev Environment ──────────────────────────────────────────
# Sync and build the managed local goose checkout used for goose2 integration.
goose-sync:
GOOSE_DEV_MODE=required ./scripts/ensure-local-goose.sh
# Install dependencies
setup:
pnpm install
GOOSE_DEV_MODE=required ./scripts/ensure-local-goose.sh
cd src-tauri && cargo build
# ── Build & Check ────────────────────────────────────────────
@@ -74,6 +79,12 @@ dev:
#!/usr/bin/env bash
set -euo pipefail
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)"
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
echo "Using local goose binary: ${GOOSE_BIN}"
fi
# Derive a stable port from the working directory so the same worktree
# always gets the same port. This avoids changing TAURI_CONFIG between
# runs, which would invalidate Cargo's build cache and trigger a full
@@ -107,6 +118,12 @@ dev-debug:
#!/usr/bin/env bash
set -euo pipefail
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)"
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
echo "Using local goose binary: ${GOOSE_BIN}"
fi
VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)")
export VITE_PORT
EXTRA_CONFIG="--config {\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort\",\"cwd\":\"..\",\"wait\":false}}}"
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: ensure-local-goose.sh [--print-bin | --check-bin]
Syncs and builds a dedicated local goose checkout for goose2 development.
Environment variables:
GOOSE_DEV_MODE auto|required (default: auto)
GOOSE_DEV_ROOT path to the shared goose2 dev cache root
(default: platform cache dir under home)
GOOSE_DEV_REPO path to the managed goose checkout
(default: $GOOSE_DEV_ROOT/goose)
GOOSE_DEV_STAMP_FILE path to the shared build stamp file
(default: $GOOSE_DEV_ROOT/stamp.env)
GOOSE_DEV_CLONE_URL git clone URL for the managed goose checkout
(default: https://github.com/block/goose.git)
GOOSE_DEV_REMOTE git remote to sync from (default: origin)
GOOSE_DEV_BRANCH preferred branch to use (default: baxen/goose2)
GOOSE_DEV_FALLBACK_BRANCH fallback branch when the preferred branch does
not exist remotely (default: main)
GOOSE_DEV_ALLOW_DIRTY 1 to allow syncing/building a dirty checkout
EOF
}
action="build"
print_bin=0
while [[ $# -gt 0 ]]; do
case "$1" in
--print-bin)
print_bin=1
shift
;;
--check-bin)
action="check"
print_bin=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
mode="${GOOSE_DEV_MODE:-auto}"
clone_url="${GOOSE_DEV_CLONE_URL:-https://github.com/block/goose.git}"
remote="${GOOSE_DEV_REMOTE:-origin}"
preferred_branch="${GOOSE_DEV_BRANCH:-baxen/goose2}"
fallback_branch="${GOOSE_DEV_FALLBACK_BRANCH:-main}"
allow_dirty="${GOOSE_DEV_ALLOW_DIRTY:-0}"
log() {
echo "[goose-dev] $*" >&2
}
fail_or_skip() {
local message="$1"
if [[ "${mode}" == "required" ]]; then
echo "${message}" >&2
exit 1
fi
log "${message}"
exit 0
}
default_goose_dev_root() {
if [[ -n "${XDG_CACHE_HOME:-}" ]]; then
printf '%s/goose2-dev\n' "${XDG_CACHE_HOME}"
return
fi
case "$(uname -s)" in
Darwin)
printf '%s/Library/Caches/goose2-dev\n' "${HOME}"
;;
*)
printf '%s/.cache/goose2-dev\n' "${HOME}"
;;
esac
}
goose_dev_root="${GOOSE_DEV_ROOT:-$(default_goose_dev_root)}"
goose_repo="${GOOSE_DEV_REPO:-${goose_dev_root}/goose}"
stamp_file="${GOOSE_DEV_STAMP_FILE:-${goose_dev_root}/stamp.env}"
bin_path="${goose_repo}/target/debug/goose"
resolve_remote_head() {
local branch_name="$1"
git -C "${goose_repo}" ls-remote --heads "${remote}" "${branch_name}" 2>/dev/null | awk 'NR == 1 { print $1 }'
}
resolve_branch() {
local resolved_branch="${preferred_branch}"
local resolved_head
resolved_head="$(resolve_remote_head "${resolved_branch}")"
if [[ -z "${resolved_head}" && "${resolved_branch}" != "${fallback_branch}" ]]; then
log "Remote branch ${remote}/${resolved_branch} not found; falling back to ${remote}/${fallback_branch}."
resolved_branch="${fallback_branch}"
resolved_head="$(resolve_remote_head "${resolved_branch}")"
fi
if [[ -z "${resolved_head}" ]]; then
if [[ "${mode}" == "required" ]]; then
echo "Could not resolve ${remote}/${resolved_branch} for managed goose checkout at ${goose_repo}." >&2
return 1
fi
log "Could not resolve ${remote}/${resolved_branch} for managed goose checkout at ${goose_repo}."
return 2
fi
RESOLVED_BRANCH="${resolved_branch}"
RESOLVED_REMOTE_HEAD="${resolved_head}"
return 0
}
write_stamp() {
local branch_name="$1"
local commit_sha="$2"
mkdir -p "$(dirname "${stamp_file}")"
{
printf 'STAMP_REPO=%q\n' "${goose_repo}"
printf 'STAMP_BRANCH=%q\n' "${branch_name}"
printf 'STAMP_COMMIT=%q\n' "${commit_sha}"
printf 'STAMP_BIN=%q\n' "${bin_path}"
} >"${stamp_file}"
}
ensure_checkout_exists() {
if [[ -d "${goose_repo}/.git" ]]; then
return 0
fi
if [[ "${action}" == "check" ]]; then
fail_or_skip "Managed goose checkout not found at ${goose_repo}. Rerun just setup."
fi
log "Cloning managed goose checkout into ${goose_repo}."
mkdir -p "$(dirname "${goose_repo}")"
git clone "${clone_url}" "${goose_repo}" >/dev/null 2>&1 || {
fail_or_skip "Failed to clone managed goose checkout from ${clone_url} into ${goose_repo}."
}
}
ensure_checkout_exists
if [[ "${allow_dirty}" != "1" ]]; then
if [[ -n "$(git -C "${goose_repo}" status --porcelain)" ]]; then
fail_or_skip "Managed goose checkout at ${goose_repo} is dirty. Use a dedicated checkout or set GOOSE_DEV_ALLOW_DIRTY=1."
fi
fi
if resolve_branch; then
branch="${RESOLVED_BRANCH}"
remote_head="${RESOLVED_REMOTE_HEAD}"
else
resolve_branch_status=$?
case "${resolve_branch_status}" in
1)
exit 1
;;
2)
exit 0
;;
*)
echo "Unexpected resolve_branch status: ${resolve_branch_status}" >&2
exit 1
;;
esac
fi
if [[ "${action}" == "check" ]]; then
if [[ ! -f "${stamp_file}" ]]; then
fail_or_skip "Managed goose checkout is configured, but no local goose build stamp was found. Rerun just setup."
fi
# shellcheck disable=SC1090
source "${stamp_file}"
if [[ "${STAMP_REPO:-}" != "${goose_repo}" ]]; then
fail_or_skip "Managed goose checkout changed since the last local goose build. Rerun just setup."
fi
if [[ "${STAMP_BRANCH:-}" != "${branch}" ]]; then
fail_or_skip "Managed goose branch is now ${branch}, but the local goose build was prepared for ${STAMP_BRANCH:-unknown}. Rerun just setup."
fi
if [[ ! -x "${STAMP_BIN:-}" ]]; then
fail_or_skip "Local goose binary was not found at ${STAMP_BIN:-unknown}. Rerun just setup."
fi
local_head="$(git -C "${goose_repo}" rev-parse HEAD)"
if [[ "${STAMP_COMMIT:-}" != "${local_head}" ]]; then
fail_or_skip "Managed goose checkout changed after the last local build. Rerun just setup."
fi
if [[ "${STAMP_COMMIT:-}" != "${remote_head}" ]]; then
fail_or_skip "Managed goose checkout is behind ${remote}/${branch}. Rerun just setup."
fi
if [[ "${print_bin}" == "1" ]]; then
printf '%s\n' "${STAMP_BIN}"
fi
exit 0
fi
git -C "${goose_repo}" fetch "${remote}" "${branch}" >/dev/null 2>&1
remote_ref="refs/remotes/${remote}/${branch}"
if ! git -C "${goose_repo}" show-ref --verify --quiet "${remote_ref}"; then
fail_or_skip "Fetched ${remote}/${branch}, but ${remote_ref} is not available in ${goose_repo}."
fi
if git -C "${goose_repo}" show-ref --verify --quiet "refs/heads/${branch}"; then
git -C "${goose_repo}" checkout "${branch}" >/dev/null 2>&1
else
git -C "${goose_repo}" checkout -b "${branch}" --track "${remote}/${branch}" >/dev/null 2>&1
fi
git -C "${goose_repo}" pull --ff-only "${remote}" "${branch}" >/dev/null 2>&1
log "Building goose from ${goose_repo} on ${branch}."
(
cd "${goose_repo}"
cargo build -p goose-cli --bin goose
)
if [[ -n "$(git -C "${goose_repo}" status --porcelain -- Cargo.lock)" ]]; then
# Cargo may refresh the lockfile while compiling a freshly synced checkout.
# This managed repo is only a build source for goose2, so restore the tracked
# lockfile to keep the checkout clean for later preflight checks.
git -C "${goose_repo}" checkout -- Cargo.lock
fi
if [[ ! -x "${bin_path}" ]]; then
echo "Expected goose binary at ${bin_path}, but it was not built successfully." >&2
exit 1
fi
write_stamp "${branch}" "$(git -C "${goose_repo}" rev-parse HEAD)"
log "Local goose binary ready at ${bin_path}."
if [[ "${print_bin}" == "1" ]]; then
printf '%s\n' "${bin_path}"
fi
+354
View File
@@ -56,6 +56,17 @@ dependencies = [
"strum",
]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -80,6 +91,23 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -95,6 +123,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -303,6 +337,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -346,6 +392,30 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "borsh"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -373,6 +443,40 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -735,6 +839,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -965,6 +1075,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1028,6 +1148,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1108,6 +1237,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1485,6 +1620,7 @@ name = "goose2"
version = "0.1.0"
dependencies = [
"acp-client",
"agent-client-protocol",
"async-trait",
"chrono",
"dirs",
@@ -1497,11 +1633,13 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-window-state",
"tempfile",
"tokio",
"tokio-stream",
"tokio-tungstenite",
"tokio-util",
"uuid",
]
@@ -1563,6 +1701,9 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
@@ -2139,6 +2280,9 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "mac"
@@ -2342,6 +2486,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -2923,6 +3076,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -2953,6 +3126,12 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3109,6 +3288,15 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3167,6 +3355,52 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust_decimal"
version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3286,6 +3520,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3513,6 +3753,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",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3546,6 +3797,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -3825,6 +4082,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4003,6 +4266,28 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
@@ -4222,7 +4507,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -4255,6 +4542,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -4294,6 +4596,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4518,6 +4832,25 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4637,6 +4970,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4655,6 +4994,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -5549,6 +5894,15 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
+4 -1
View File
@@ -21,6 +21,7 @@ tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2"
tauri-plugin-log = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6.0.0"
@@ -31,8 +32,10 @@ chrono = { version = "0.4", features = ["serde"] }
serde_yaml = "0.9"
futures = "0.3"
tokio-stream = "0.1"
tokio-util = { version = "0.7", features = ["rt"] }
tokio-util = { version = "0.7", features = ["compat", "rt"] }
async-trait = "0.1"
agent-client-protocol = "0.10.4"
tokio-tungstenite = "0.21.0"
acp-client = { git = "https://github.com/block/builderbot", rev = "dbd5bc9c0cd559c7a67ab2d56545dba110f4a4cb" }
doctor = { git = "https://github.com/block/builderbot", rev = "bfce29264181236a2e5b4fd903ace6291f6a7594" }
File diff suppressed because one or more lines are too long
@@ -2378,6 +2378,24 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`",
"type": "string",
"const": "log:default",
"markdownDescription": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "log:allow-log",
"markdownDescription": "Enables the log command without any pre-configured scope."
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "log:deny-log",
"markdownDescription": "Denies the log command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
@@ -2378,6 +2378,24 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`",
"type": "string",
"const": "log:default",
"markdownDescription": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "log:allow-log",
"markdownDescription": "Enables the log command without any pre-configured scope."
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "log:deny-log",
"markdownDescription": "Denies the log command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
+60 -11
View File
@@ -3,12 +3,15 @@ use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, State};
use crate::services::acp::{make_composite_key, AcpRunningSession, AcpService, AcpSessionRegistry};
use crate::services::acp::{
make_composite_key, AcpRunningSession, AcpService, AcpSessionRegistry, GooseAcpManager,
};
use crate::services::sessions::SessionStore;
use crate::types::messages::{
MessageCompletionStatus, MessageContent, MessageMetadata, ToolCallStatus,
};
use acp_client::discover_providers;
const DEPRECATED_PROVIDER_IDS: &[&str] = &["claude-code", "codex", "gemini-cli"];
/// Response type for an ACP provider, sent to the frontend.
#[derive(Serialize)]
@@ -18,6 +21,10 @@ pub struct AcpProviderResponse {
label: String,
}
fn should_include_provider(provider_id: &str) -> bool {
!DEPRECATED_PROVIDER_IDS.contains(&provider_id)
}
fn default_artifacts_working_dir() -> PathBuf {
if let Some(home_dir) = dirs::home_dir() {
return home_dir.join(".goose").join("artifacts");
@@ -76,16 +83,21 @@ fn resolve_working_dir(
})
}
/// Discover all locally available ACP providers.
/// Return the list of providers available through goose serve.
#[tauri::command]
pub async fn discover_acp_providers() -> Vec<AcpProviderResponse> {
discover_providers()
pub async fn discover_acp_providers(
app_handle: AppHandle,
) -> Result<Vec<AcpProviderResponse>, String> {
let manager = GooseAcpManager::start(app_handle).await?;
let providers = manager.list_providers().await?;
Ok(providers
.into_iter()
.map(|p| AcpProviderResponse {
id: p.id,
label: p.label,
.filter(|provider| should_include_provider(&provider.id))
.map(|provider| AcpProviderResponse {
id: provider.id,
label: provider.label,
})
.collect()
.collect())
}
/// Send a prompt to an ACP agent and stream the response via Tauri events.
@@ -127,9 +139,33 @@ pub async fn acp_send_message(
.await
}
#[tauri::command]
pub async fn acp_prepare_session(
app_handle: AppHandle,
session_store: State<'_, Arc<SessionStore>>,
session_id: String,
provider_id: String,
working_dir: Option<String>,
persona_id: Option<String>,
) -> Result<(), String> {
let current_dir = std::env::current_dir()
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
let working_dir = resolve_working_dir(working_dir, &current_dir)?;
AcpService::prepare_session(
app_handle,
session_id,
provider_id,
working_dir,
persona_id,
Arc::clone(&session_store),
)
.await
}
#[cfg(test)]
mod tests {
use super::{expand_home_dir, resolve_working_dir};
use super::{expand_home_dir, resolve_working_dir, should_include_provider};
use std::path::PathBuf;
#[test]
@@ -172,6 +208,16 @@ mod tests {
home_dir.join("Code/goose2")
);
}
#[test]
fn provider_discovery_hides_deprecated_cli_providers() {
assert!(!should_include_provider("claude-code"));
assert!(!should_include_provider("codex"));
assert!(!should_include_provider("gemini-cli"));
assert!(should_include_provider("goose"));
assert!(should_include_provider("claude-acp"));
assert!(should_include_provider("codex-acp"));
}
}
/// Cancel a running ACP session.
@@ -180,6 +226,7 @@ mod tests {
/// is used so only that persona's stream is cancelled.
#[tauri::command]
pub async fn acp_cancel_session(
app_handle: AppHandle,
registry: State<'_, Arc<AcpSessionRegistry>>,
session_store: State<'_, Arc<SessionStore>>,
session_id: String,
@@ -187,6 +234,8 @@ pub async fn acp_cancel_session(
) -> Result<bool, String> {
let key = make_composite_key(&session_id, persona_id.as_deref());
let assistant_message_id = registry.cancel(&key);
let manager = GooseAcpManager::start(app_handle).await?;
let was_cancelled = manager.cancel_session(key).await?;
if let Some(message_id) = assistant_message_id.as_deref() {
let _ = session_store.update_message(&session_id, message_id, |message| {
@@ -203,7 +252,7 @@ pub async fn acp_cancel_session(
});
}
Ok(assistant_message_id.is_some())
Ok(was_cancelled)
}
/// List all currently running ACP sessions.
+10
View File
@@ -18,6 +18,14 @@ pub fn run() {
let acp_registry_for_exit = Arc::clone(&acp_registry);
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Debug)
.targets([tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
)])
.build(),
)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
@@ -49,6 +57,7 @@ pub fn run() {
commands::sessions::unarchive_session,
commands::chat::chat_send_message,
commands::acp::discover_acp_providers,
commands::acp::acp_prepare_session,
commands::acp::acp_send_message,
commands::acp::acp_cancel_session,
commands::acp::acp_list_running,
@@ -73,6 +82,7 @@ pub fn run() {
commands::system::get_home_dir,
commands::system::path_exists,
])
.setup(|_app| Ok(()))
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(move |_app, event| {
@@ -0,0 +1,266 @@
use std::path::PathBuf;
use std::time::{Duration, Instant};
use futures::{SinkExt, StreamExt};
use tokio::process::{Child, Command};
use tokio::sync::OnceCell;
use tokio_tungstenite::connect_async;
const GOOSE_SERVE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const GOOSE_SERVE_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(100);
const LOCALHOST: &str = "127.0.0.1";
pub(crate) const WS_BRIDGE_BUFFER_BYTES: usize = 64 * 1024;
// ---------------------------------------------------------------------------
// GooseServeProcess — singleton that owns the long-lived `goose serve` child
// ---------------------------------------------------------------------------
/// A long-lived `goose serve` process that accepts WebSocket connections.
///
/// Each WebSocket connection to the `/acp` endpoint creates an independent
/// ACP agent inside the server, so a single process can serve any number of
/// concurrent sessions.
pub struct GooseServeProcess {
port: u16,
_child: Child,
}
/// Global singleton — initialised once at app startup.
static GOOSE_SERVE: OnceCell<GooseServeProcess> = OnceCell::const_new();
impl GooseServeProcess {
/// Return the WebSocket URL for connecting to this server.
pub fn ws_url(&self) -> String {
format!("ws://{LOCALHOST}:{}/acp", self.port)
}
/// Start the singleton `goose serve` process.
///
/// This is called once from `lib.rs` during app startup. Subsequent calls
/// are no-ops (the `OnceCell` ensures single initialisation). The process
/// is spawned with `kill_on_drop(true)` so it is automatically terminated
/// when the Tauri app exits.
pub async fn start() -> Result<(), String> {
GOOSE_SERVE
.get_or_try_init(|| async { Self::spawn().await })
.await
.map(|_| ())
}
/// Get a reference to the running process, or an error if it was never
/// started (should not happen in normal operation).
pub fn get() -> Result<&'static GooseServeProcess, String> {
GOOSE_SERVE
.get()
.ok_or_else(|| "Goose serve process has not been started".to_string())
}
async fn spawn() -> Result<GooseServeProcess, String> {
let binary_path = resolve_goose_binary()?;
let port = reserve_free_port()?;
// Use a stable working directory for the long-lived server process.
// Individual sessions will set their own cwd via the ACP protocol.
let working_dir = default_serve_working_dir();
std::fs::create_dir_all(&working_dir).map_err(|e| {
format!(
"Failed to create goose serve working directory {}: {e}",
working_dir.display()
)
})?;
let mut command = Command::new(&binary_path);
command
.arg("serve")
.arg("--host")
.arg(LOCALHOST)
.arg("--port")
.arg(port.to_string())
.current_dir(&working_dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
log::info!(
"Spawning long-lived goose serve: binary={} port={} cwd={}",
binary_path.display(),
port,
working_dir.display(),
);
let mut child = command.spawn().map_err(|error| {
format!(
"Failed to spawn goose serve (binary: {}, cwd: {}): {error}",
binary_path.display(),
working_dir.display()
)
})?;
// Wait for the server to become ready by polling the WebSocket endpoint.
let ws_url = format!("ws://{LOCALHOST}:{port}/acp");
wait_for_server_ready(&ws_url, &mut child).await?;
log::info!("Goose serve is ready on port {port}");
Ok(GooseServeProcess {
port,
_child: child,
})
}
}
/// Wait for the goose serve process to accept WebSocket connections.
///
/// We do a connect-then-immediately-close loop until the server responds,
/// the child exits, or we time out.
async fn wait_for_server_ready(ws_url: &str, child: &mut Child) -> Result<(), String> {
let deadline = Instant::now() + GOOSE_SERVE_CONNECT_TIMEOUT;
loop {
match connect_async(ws_url).await {
Ok((ws_stream, _)) => {
// Server is up — close the probe connection.
let (mut writer, _) = ws_stream.split();
let _ = writer.close().await;
return Ok(());
}
Err(connect_error) => {
if let Some(status) = child
.try_wait()
.map_err(|e| format!("Failed to poll goose serve process: {e}"))?
{
return Err(format!(
"Goose serve exited before becoming ready: {status}"
));
}
if Instant::now() >= deadline {
return Err(format!(
"Timed out waiting for goose serve at {ws_url}: {connect_error}"
));
}
tokio::time::sleep(GOOSE_SERVE_CONNECT_RETRY_DELAY).await;
}
}
}
}
fn default_serve_working_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".goose")
.join("artifacts")
}
// ---------------------------------------------------------------------------
// Binary resolution
// ---------------------------------------------------------------------------
fn resolve_goose_binary() -> Result<PathBuf, String> {
let binary_path = if let Ok(override_path) = std::env::var("GOOSE_BIN") {
let path = PathBuf::from(&override_path);
if !path.exists() {
return Err(format!(
"GOOSE_BIN points to non-existent path: {override_path}"
));
}
if !goose_binary_supports_serve(&path)? {
return Err(format!(
"GOOSE_BIN points to a goose binary without `serve` support: {}",
path.display()
));
}
log::info!("Using GOOSE_BIN override: {override_path}");
path
} else {
let agent = acp_client::find_acp_agent_by_id("goose")
.ok_or_else(|| "Unknown or unavailable agent provider: goose".to_string())?;
if !goose_binary_supports_serve(&agent.binary_path)? {
return Err(format!(
"Resolved goose binary does not support `serve`: {}. Set GOOSE_BIN to a newer goose binary.",
agent.binary_path.display()
));
}
log::info!(
"Resolved goose binary via login-shell discovery: {}",
agent.binary_path.display()
);
agent.binary_path
};
// Log the binary version for debugging.
match std::process::Command::new(&binary_path)
.env("GOOSE_PATH_ROOT", goose_probe_root())
.arg("--version")
.output()
{
Ok(output) => {
let version = String::from_utf8_lossy(&output.stdout);
log::info!(
"Goose binary version: {} (path: {})",
version.trim(),
binary_path.display()
);
}
Err(err) => {
log::warn!(
"Could not determine goose binary version at {}: {err}",
binary_path.display()
);
}
}
Ok(binary_path)
}
fn goose_binary_supports_serve(binary_path: &PathBuf) -> Result<bool, String> {
let output = std::process::Command::new(binary_path)
.env("GOOSE_PATH_ROOT", goose_probe_root())
.arg("serve")
.arg("--help")
.output()
.map_err(|error| {
format!(
"Failed to probe goose binary {}: {error}",
binary_path.display()
)
})?;
if output.status.success() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
log::warn!(
"Goose binary probe failed for {}: status={} stderr={} stdout={}",
binary_path.display(),
output
.status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "signal".to_string()),
stderr.trim(),
stdout.trim(),
);
Ok(false)
}
fn goose_probe_root() -> PathBuf {
std::env::temp_dir().join("block-goose2-goose-probe")
}
fn reserve_free_port() -> Result<u16, String> {
let listener = std::net::TcpListener::bind((LOCALHOST, 0))
.map_err(|error| format!("Failed to reserve Goose serve port: {error}"))?;
listener
.local_addr()
.map(|address| address.port())
.map_err(|error| format!("Failed to resolve reserved Goose serve port: {error}"))
}
@@ -0,0 +1,446 @@
mod dispatcher;
mod session_ops;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::{MessageWriter, Store};
use agent_client_protocol::{
Agent, ClientSideConnection, ExtRequest, Implementation, InitializeRequest, ProtocolVersion,
};
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::value::RawValue;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex, OnceCell};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use super::goose_serve::{GooseServeProcess, WS_BRIDGE_BUFFER_BYTES};
use dispatcher::{SessionEventDispatcher, SessionRoute};
use session_ops::{
cancel_session_inner, prepare_session_inner, send_prompt_inner, ManagerState,
PrepareSessionInput,
};
enum ManagerCommand {
ListProviders {
response: oneshot::Sender<Result<Vec<GooseAcpProvider>, String>>,
},
PrepareSession {
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
response: oneshot::Sender<Result<(), String>>,
},
SendPrompt {
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
response: oneshot::Sender<Result<(), String>>,
},
CancelSession {
composite_key: String,
response: oneshot::Sender<Result<bool, String>>,
},
}
pub struct GooseAcpManager {
command_tx: mpsc::UnboundedSender<ManagerCommand>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GooseAcpProvider {
pub id: String,
pub label: String,
}
#[derive(Debug, Deserialize)]
struct GooseProvidersResponse {
providers: Vec<GooseAcpProvider>,
}
static GOOSE_ACP_MANAGER: OnceCell<Arc<GooseAcpManager>> = OnceCell::const_new();
impl GooseAcpManager {
pub async fn start(app_handle: tauri::AppHandle) -> Result<Arc<Self>, String> {
GOOSE_ACP_MANAGER
.get_or_try_init(|| async move {
let (command_tx, command_rx) = mpsc::unbounded_channel();
let manager = Arc::new(Self { command_tx });
let (ready_tx, ready_rx) = oneshot::channel();
std::thread::Builder::new()
.name("goose-acp-manager".to_string())
.spawn(move || run_manager_thread(app_handle, command_rx, ready_tx))
.map_err(|error| {
format!("Failed to spawn Goose ACP manager thread: {error}")
})?;
ready_rx
.await
.map_err(|_| "Goose ACP manager failed before initialization".to_string())??;
Ok(manager)
})
.await
.map(Arc::clone)
}
pub async fn prepare_session(
&self,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::PrepareSession {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped prepare request".to_string())?
}
pub async fn list_providers(&self) -> Result<Vec<GooseAcpProvider>, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::ListProviders {
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped provider discovery request".to_string())?
}
#[allow(clippy::too_many_arguments)]
pub async fn send_prompt(
&self,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::SendPrompt {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
writer,
prompt,
images,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped prompt request".to_string())?
}
pub async fn cancel_session(&self, composite_key: String) -> Result<bool, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::CancelSession {
composite_key,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped cancel request".to_string())?
}
}
fn run_manager_thread(
app_handle: tauri::AppHandle,
mut command_rx: mpsc::UnboundedReceiver<ManagerCommand>,
ready_tx: oneshot::Sender<Result<(), String>>,
) {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
let _ = ready_tx.send(Err(format!(
"Failed to build Goose ACP manager runtime: {error}"
)));
return;
}
};
let local = tokio::task::LocalSet::new();
local.block_on(&runtime, async move {
let setup_result = async {
GooseServeProcess::start().await?;
let process = GooseServeProcess::get()?;
let ws_stream = connect_async(process.ws_url())
.await
.map(|(stream, _)| stream)
.map_err(|error| format!("Failed to connect to goose serve: {error}"))?;
let (mut ws_writer, mut ws_reader) = ws_stream.split();
let (connection_outgoing, bridge_outgoing_reader) =
tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES);
let (bridge_incoming_writer, connection_incoming) =
tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES);
tokio::task::spawn_local(async move {
let mut reader = BufReader::new(bridge_outgoing_reader);
let mut line = String::new();
loop {
line.clear();
let bytes_read = match reader.read_line(&mut line).await {
Ok(bytes_read) => bytes_read,
Err(error) => {
log::error!("Failed to read ACP output for Goose WebSocket: {error}");
break;
}
};
if bytes_read == 0 {
break;
}
let payload = line.trim_end_matches(['\r', '\n']);
if payload.is_empty() {
continue;
}
if let Err(error) = ws_writer.send(Message::Text(payload.to_string())).await {
log::error!("Failed to write Goose WebSocket frame: {error}");
break;
}
}
let _ = ws_writer.close().await;
});
tokio::task::spawn_local(async move {
let mut writer = bridge_incoming_writer;
while let Some(message) = ws_reader.next().await {
match message {
Ok(Message::Text(text)) => {
if let Err(error) = writer.write_all(text.as_bytes()).await {
log::error!(
"Failed to forward Goose WebSocket payload to ACP client: {error}"
);
break;
}
if let Err(error) = writer.write_all(b"\n").await {
log::error!(
"Failed to delimit Goose WebSocket payload for ACP client: {error}"
);
break;
}
if let Err(error) = writer.flush().await {
log::error!("Failed to flush Goose WebSocket payload: {error}");
break;
}
}
Ok(Message::Close(_)) => break,
Ok(Message::Binary(_))
| Ok(Message::Ping(_))
| Ok(Message::Pong(_))
| Ok(Message::Frame(_)) => {}
Err(error) => {
log::error!("Goose WebSocket read failed: {error}");
break;
}
}
}
let _ = writer.shutdown().await;
});
let routes = Arc::new(Mutex::new(HashMap::<String, SessionRoute>::new()));
let dispatcher = Arc::new(SessionEventDispatcher::new(
app_handle.clone(),
Arc::clone(&routes),
));
let (connection, io_future) = ClientSideConnection::new(
dispatcher.clone(),
connection_outgoing.compat_write(),
connection_incoming.compat(),
|future| {
tokio::task::spawn_local(future);
},
);
let connection = Arc::new(connection);
tokio::task::spawn_local(async move {
if let Err(error) = io_future.await {
log::error!("Goose ACP IO error: {error:?}");
}
});
connection
.initialize(
InitializeRequest::new(ProtocolVersion::LATEST)
.client_info(Implementation::new("goose2", env!("CARGO_PKG_VERSION"))),
)
.await
.map_err(|error| format!("Goose ACP initialize failed: {error:?}"))?;
Ok::<_, String>((connection, dispatcher))
}
.await;
let (connection, dispatcher) = match setup_result {
Ok(parts) => {
let _ = ready_tx.send(Ok(()));
parts
}
Err(error) => {
let _ = ready_tx.send(Err(error));
return;
}
};
let state = Arc::new(Mutex::new(ManagerState {
sessions: HashMap::new(),
op_locks: HashMap::new(),
pending_cancels: HashSet::new(),
preparing_sessions: HashSet::new(),
}));
while let Some(command) = command_rx.recv().await {
match command {
ManagerCommand::ListProviders { response } => {
let connection = Arc::clone(&connection);
tokio::task::spawn_local(async move {
let result = async {
let params = RawValue::from_string("{}".to_string())
.map_err(|error| format!("Failed to build ACP request body: {error}"))?;
let response_value = connection
.ext_method(ExtRequest::new("goose/providers/list", params.into()))
.await
.map_err(|error| {
format!("Failed to list providers via Goose ACP: {error:?}")
})?;
let parsed: GooseProvidersResponse =
serde_json::from_str(response_value.0.get()).map_err(|error| {
format!("Failed to decode Goose provider list: {error}")
})?;
Ok::<_, String>(parsed.providers)
}
.await;
let _ = response.send(result);
});
}
ManagerCommand::PrepareSession {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result = prepare_session_inner(
&connection,
&dispatcher,
&state,
PrepareSessionInput {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
},
)
.await
.map(|_| ());
let _ = response.send(result);
});
}
ManagerCommand::SendPrompt {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
writer,
prompt,
images,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result = send_prompt_inner(
&connection,
&dispatcher,
&state,
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
writer,
prompt,
images,
)
.await;
let _ = response.send(result);
});
}
ManagerCommand::CancelSession {
composite_key,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result =
cancel_session_inner(&connection, &dispatcher, &state, &composite_key)
.await;
let _ = response.send(result);
});
}
}
}
});
}
@@ -0,0 +1,268 @@
use std::collections::HashMap;
use std::sync::Arc;
use acp_client::MessageWriter;
use agent_client_protocol::{
Client, ContentBlock as AcpContentBlock, PermissionOptionId, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
SessionConfigSelectOptions, SessionInfoUpdate, SessionModelState, SessionNotification,
SessionUpdate,
};
use async_trait::async_trait;
use tauri::Emitter;
use tokio::sync::Mutex;
use crate::services::acp::payloads::{ModelStatePayload, SessionInfoPayload};
#[derive(Clone)]
pub(super) struct SessionRoute {
pub(super) local_session_id: String,
pub(super) writer: Option<Arc<dyn MessageWriter>>,
pub(super) canceled: bool,
}
pub(super) struct SessionEventDispatcher {
app_handle: tauri::AppHandle,
routes: Arc<Mutex<HashMap<String, SessionRoute>>>,
}
impl SessionEventDispatcher {
pub(super) fn new(
app_handle: tauri::AppHandle,
routes: Arc<Mutex<HashMap<String, SessionRoute>>>,
) -> Self {
Self { app_handle, routes }
}
pub(super) async fn bind_session(&self, goose_session_id: &str, local_session_id: &str) {
let mut routes = self.routes.lock().await;
routes
.entry(goose_session_id.to_string())
.and_modify(|route| route.local_session_id = local_session_id.to_string())
.or_insert_with(|| SessionRoute {
local_session_id: local_session_id.to_string(),
writer: None,
canceled: false,
});
}
pub(super) async fn attach_writer(
&self,
goose_session_id: &str,
local_session_id: &str,
writer: Arc<dyn MessageWriter>,
) {
let mut routes = self.routes.lock().await;
routes.insert(
goose_session_id.to_string(),
SessionRoute {
local_session_id: local_session_id.to_string(),
writer: Some(writer),
canceled: false,
},
);
}
pub(super) async fn clear_writer(&self, goose_session_id: &str) {
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(goose_session_id) {
route.writer = None;
route.canceled = false;
}
}
pub(super) async fn mark_canceled(&self, goose_session_id: &str) -> bool {
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(goose_session_id) {
let had_writer = route.writer.is_some();
route.writer = None;
route.canceled = had_writer;
return had_writer;
}
false
}
pub(super) async fn is_canceled(&self, goose_session_id: &str) -> bool {
let routes = self.routes.lock().await;
routes
.get(goose_session_id)
.is_some_and(|route| route.canceled)
}
pub(super) fn emit_session_info(&self, local_session_id: &str, info: &SessionInfoUpdate) {
let _ = self.app_handle.emit(
"acp:session_info",
SessionInfoPayload {
session_id: local_session_id.to_string(),
title: info.title.value().cloned(),
},
);
}
pub(super) fn emit_model_state(&self, local_session_id: &str, state: &SessionModelState) {
let current_model_name = state
.available_models
.iter()
.find(|model| model.model_id == state.current_model_id)
.map(|model| model.name.clone());
let _ = self.app_handle.emit(
"acp:model_state",
ModelStatePayload {
session_id: local_session_id.to_string(),
current_model_id: state.current_model_id.to_string(),
current_model_name,
},
);
}
pub(super) fn emit_model_state_from_options(
&self,
local_session_id: &str,
options: &[SessionConfigOption],
) {
let Some(option) = options
.iter()
.find(|option| option.category == Some(SessionConfigOptionCategory::Model))
else {
return;
};
let SessionConfigKind::Select(select) = &option.kind else {
return;
};
let current_model_id = select.current_value.to_string();
let current_model_name = match &select.options {
SessionConfigSelectOptions::Ungrouped(values) => values
.iter()
.find(|value| value.value == select.current_value)
.map(|value| value.name.clone()),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| group.options.iter())
.find(|value| value.value == select.current_value)
.map(|value| value.name.clone()),
_ => None,
};
let _ = self.app_handle.emit(
"acp:model_state",
ModelStatePayload {
session_id: local_session_id.to_string(),
current_model_id,
current_model_name,
},
);
}
}
fn extract_content_preview(content: &[agent_client_protocol::ToolCallContent]) -> Option<String> {
for item in content {
match item {
agent_client_protocol::ToolCallContent::Content(content_item) => {
if let AcpContentBlock::Text(text) = &content_item.content {
let preview: String = text.text.chars().take(500).collect();
return Some(if text.text.len() > 500 {
format!("{preview}…")
} else {
preview
});
}
}
agent_client_protocol::ToolCallContent::Diff(diff) => {
return Some(format!(
"{}{}",
diff.path.display(),
if diff.old_text.is_some() {
" (modified)"
} else {
" (new)"
}
));
}
agent_client_protocol::ToolCallContent::Terminal(terminal) => {
return Some(format!("Terminal: {}", terminal.terminal_id.0));
}
_ => {}
}
}
None
}
#[async_trait(?Send)]
impl Client for SessionEventDispatcher {
async fn request_permission(
&self,
args: RequestPermissionRequest,
) -> agent_client_protocol::Result<RequestPermissionResponse> {
let option_id = args
.options
.first()
.map(|option| option.option_id.clone())
.unwrap_or_else(|| PermissionOptionId::new("approve"));
Ok(RequestPermissionResponse::new(
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)),
))
}
async fn session_notification(
&self,
notification: SessionNotification,
) -> agent_client_protocol::Result<()> {
let goose_session_id = notification.session_id.to_string();
let route = {
let routes = self.routes.lock().await;
routes.get(&goose_session_id).cloned()
};
let Some(route) = route else {
return Ok(());
};
match &notification.update {
SessionUpdate::SessionInfoUpdate(info) => {
self.emit_session_info(&route.local_session_id, info);
return Ok(());
}
SessionUpdate::ConfigOptionUpdate(update) => {
self.emit_model_state_from_options(&route.local_session_id, &update.config_options);
return Ok(());
}
_ => {}
}
let Some(writer) = route.writer else {
return Ok(());
};
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => {
if let AcpContentBlock::Text(text) = &chunk.content {
writer.append_text(&text.text).await;
}
}
SessionUpdate::ToolCall(tool_call) => {
writer
.record_tool_call(&tool_call.tool_call_id.0, &tool_call.title)
.await;
}
SessionUpdate::ToolCallUpdate(update) => {
if let Some(title) = &update.fields.title {
writer
.update_tool_call_title(&update.tool_call_id.0, title)
.await;
}
if let Some(content) = &update.fields.content {
if let Some(result) = extract_content_preview(content) {
writer.record_tool_result(&result).await;
}
}
}
_ => {}
}
Ok(())
}
}
@@ -0,0 +1,416 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::{MessageWriter, Store};
use agent_client_protocol::{
Agent, CancelNotification, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent,
LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind, SessionConfigOption,
SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent,
};
use tokio::sync::Mutex;
use super::dispatcher::SessionEventDispatcher;
#[derive(Clone)]
pub(super) struct PreparedSession {
goose_session_id: String,
provider_id: String,
working_dir: PathBuf,
}
pub(super) struct ManagerState {
pub(super) sessions: HashMap<String, PreparedSession>,
pub(super) op_locks: HashMap<String, Arc<Mutex<()>>>,
pub(super) pending_cancels: HashSet<String>,
pub(super) preparing_sessions: HashSet<String>,
}
impl ManagerState {
pub(super) fn session_lock(&mut self, composite_key: &str) -> Arc<Mutex<()>> {
self.op_locks
.entry(composite_key.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
pub(super) fn mark_cancel_requested(&mut self, composite_key: &str) {
self.pending_cancels.insert(composite_key.to_string());
}
pub(super) fn take_cancel_requested(&mut self, composite_key: &str) -> bool {
self.pending_cancels.remove(composite_key)
}
}
fn extract_current_select_value(
options: &[SessionConfigOption],
option_id: &str,
) -> Option<String> {
let option = options
.iter()
.find(|candidate| candidate.id.0.as_ref() == option_id)?;
let SessionConfigKind::Select(select) = &option.kind else {
return None;
};
let current_value = select.current_value.to_string();
match &select.options {
SessionConfigSelectOptions::Ungrouped(values) => values
.iter()
.any(|value| value.value == select.current_value)
.then_some(current_value),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| group.options.iter())
.any(|value| value.value == select.current_value)
.then_some(current_value),
_ => Some(current_value),
}
}
fn needs_provider_update(current_provider_id: Option<&str>, requested_provider_id: &str) -> bool {
current_provider_id != Some(requested_provider_id)
}
pub(super) struct PrepareSessionInput {
pub(super) composite_key: String,
pub(super) local_session_id: String,
pub(super) provider_id: String,
pub(super) working_dir: PathBuf,
pub(super) existing_agent_session_id: Option<String>,
pub(super) store: Arc<dyn Store>,
}
pub(super) async fn prepare_session_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
input: PrepareSessionInput,
) -> Result<String, String> {
let PrepareSessionInput {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
} = input;
let session_lock = {
let mut guard = state.lock().await;
guard.session_lock(&composite_key)
};
let _lock_guard = session_lock.lock().await;
{
let mut guard = state.lock().await;
guard.preparing_sessions.insert(composite_key.clone());
}
let prepare_result: Result<(String, Option<PreparedSession>), String> = async {
let existing_prepared = {
let guard = state.lock().await;
guard.sessions.get(&composite_key).cloned()
};
if let Some(prepared) = existing_prepared {
dispatcher
.bind_session(&prepared.goose_session_id, &local_session_id)
.await;
if prepared.working_dir != working_dir {
let response = connection
.load_session(LoadSessionRequest::new(
prepared.goose_session_id.clone(),
working_dir.clone(),
))
.await
.map_err(|error| format!("Failed to load Goose session: {error:?}"))?;
if let Some(models) = &response.models {
dispatcher.emit_model_state(&local_session_id, models);
}
if let Some(options) = &response.config_options {
dispatcher.emit_model_state_from_options(&local_session_id, options);
}
let mut guard = state.lock().await;
if let Some(session) = guard.sessions.get_mut(&composite_key) {
session.working_dir = working_dir.clone();
}
}
if needs_provider_update(Some(&prepared.provider_id), &provider_id) {
let response = connection
.set_session_config_option(SetSessionConfigOptionRequest::new(
prepared.goose_session_id.clone(),
"provider",
provider_id.as_str(),
))
.await
.map_err(|error| {
format!("Failed to update provider via Goose ACP: {error:?}")
})?;
dispatcher
.emit_model_state_from_options(&local_session_id, &response.config_options);
let mut guard = state.lock().await;
if let Some(session) = guard.sessions.get_mut(&composite_key) {
session.provider_id = provider_id.clone();
}
}
return Ok((prepared.goose_session_id, None));
}
let goose_session_id = if let Some(existing_id) = existing_agent_session_id {
dispatcher
.bind_session(&existing_id, &local_session_id)
.await;
let response = connection
.load_session(LoadSessionRequest::new(
existing_id.clone(),
working_dir.clone(),
))
.await
.map_err(|error| format!("Failed to load Goose session: {error:?}"))?;
if let Some(models) = &response.models {
dispatcher.emit_model_state(&local_session_id, models);
}
if let Some(options) = &response.config_options {
dispatcher.emit_model_state_from_options(&local_session_id, options);
}
let loaded_provider_id = response
.config_options
.as_deref()
.and_then(|options| extract_current_select_value(options, "provider"));
if needs_provider_update(loaded_provider_id.as_deref(), &provider_id) {
let response = connection
.set_session_config_option(SetSessionConfigOptionRequest::new(
existing_id.clone(),
"provider",
provider_id.as_str(),
))
.await
.map_err(|error| {
format!("Failed to update provider via Goose ACP: {error:?}")
})?;
dispatcher
.emit_model_state_from_options(&local_session_id, &response.config_options);
}
existing_id
} else {
let mut request = NewSessionRequest::new(working_dir.clone());
if provider_id != "goose" {
let mut meta = serde_json::Map::new();
meta.insert(
"provider".into(),
serde_json::Value::String(provider_id.clone()),
);
request = request.meta(meta);
}
let response = connection
.new_session(request)
.await
.map_err(|error| format!("Failed to create Goose session: {error:?}"))?;
let new_id = response.session_id.to_string();
store
.set_agent_session_id(&local_session_id, &new_id)
.map_err(|error| format!("Failed to save Goose session ID: {error}"))?;
dispatcher.bind_session(&new_id, &local_session_id).await;
if let Some(models) = &response.models {
dispatcher.emit_model_state(&local_session_id, models);
}
if let Some(options) = &response.config_options {
dispatcher.emit_model_state_from_options(&local_session_id, options);
}
new_id
};
Ok((
goose_session_id.clone(),
Some(PreparedSession {
goose_session_id,
provider_id,
working_dir,
}),
))
}
.await;
{
let mut guard = state.lock().await;
guard.preparing_sessions.remove(&composite_key);
}
let (goose_session_id, prepared_session) = prepare_result?;
if let Some(prepared_session) = prepared_session {
let mut guard = state.lock().await;
guard.sessions.insert(composite_key, prepared_session);
}
Ok(goose_session_id)
}
async fn take_cancel_requested(state: &Arc<Mutex<ManagerState>>, composite_key: &str) -> bool {
let mut guard = state.lock().await;
guard.take_cancel_requested(composite_key)
}
async fn clear_cancel_requested(state: &Arc<Mutex<ManagerState>>, composite_key: &str) {
let mut guard = state.lock().await;
guard.pending_cancels.remove(composite_key);
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn send_prompt_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
store: Arc<dyn Store>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
) -> Result<(), String> {
let goose_session_id = match prepare_session_inner(
connection,
dispatcher,
state,
PrepareSessionInput {
composite_key: composite_key.clone(),
local_session_id: local_session_id.clone(),
provider_id,
working_dir,
existing_agent_session_id,
store,
},
)
.await
{
Ok(goose_session_id) => goose_session_id,
Err(error) => {
clear_cancel_requested(state, &composite_key).await;
return Err(error);
}
};
if take_cancel_requested(state, &composite_key).await {
return Ok(());
}
dispatcher
.attach_writer(&goose_session_id, &local_session_id, writer.clone())
.await;
if dispatcher.is_canceled(&goose_session_id).await
|| take_cancel_requested(state, &composite_key).await
{
dispatcher.clear_writer(&goose_session_id).await;
return Ok(());
}
let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))];
for (data, mime_type) in &images {
content_blocks.push(AcpContentBlock::Image(ImageContent::new(
data.as_str(),
mime_type.as_str(),
)));
}
let result = connection
.prompt(PromptRequest::new(goose_session_id.clone(), content_blocks))
.await
.map(|_| ())
.map_err(|error| format!("Prompt failed via Goose ACP: {error:?}"));
let canceled = dispatcher.is_canceled(&goose_session_id).await;
dispatcher.clear_writer(&goose_session_id).await;
clear_cancel_requested(state, &composite_key).await;
if result.is_ok() && !canceled {
writer.finalize().await;
}
result
}
pub(super) async fn cancel_session_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
composite_key: &str,
) -> Result<bool, String> {
let (goose_session_id, is_preparing) = {
let mut guard = state.lock().await;
let goose_session_id = guard
.sessions
.get(composite_key)
.map(|session| session.goose_session_id.clone());
let is_preparing = guard.preparing_sessions.contains(composite_key);
if goose_session_id.is_some() || is_preparing {
guard.mark_cancel_requested(composite_key);
}
(goose_session_id, is_preparing)
};
let Some(goose_session_id) = goose_session_id else {
return Ok(is_preparing);
};
let had_writer = dispatcher.mark_canceled(&goose_session_id).await;
if !had_writer {
return Ok(true);
}
connection
.cancel(CancelNotification::new(goose_session_id))
.await
.map_err(|error| format!("Failed to cancel Goose ACP session: {error:?}"))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use super::{needs_provider_update, ManagerState};
#[test]
fn provider_update_detects_switch_back_to_goose() {
assert!(needs_provider_update(Some("openai"), "goose"));
assert!(needs_provider_update(Some("claude-acp"), "goose"));
assert!(!needs_provider_update(Some("goose"), "goose"));
assert!(needs_provider_update(None, "goose"));
}
#[test]
fn pending_cancel_is_consumed_once() {
let mut state = ManagerState {
sessions: HashMap::new(),
op_locks: HashMap::new(),
pending_cancels: HashSet::new(),
preparing_sessions: HashSet::new(),
};
state.mark_cancel_requested("session-1");
assert!(state.take_cancel_requested("session-1"));
assert!(!state.take_cancel_requested("session-1"));
}
}
+47 -33
View File
@@ -1,10 +1,13 @@
mod catchup;
mod goose_serve;
mod manager;
mod payloads;
mod registry;
mod store;
mod writer;
pub use catchup::build_catchup_context;
pub use manager::GooseAcpManager;
pub use registry::{AcpRunningSession, AcpSessionRegistry};
pub use store::TauriStore;
pub use writer::TauriMessageWriter;
@@ -12,7 +15,7 @@ pub use writer::TauriMessageWriter;
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::{AcpDriver, AgentDriver, MessageWriter, Store};
use acp_client::{MessageWriter, Store};
use crate::services::sessions::SessionStore;
use crate::types::messages::{
@@ -44,6 +47,33 @@ pub fn split_composite_key(key: &str) -> (&str, Option<&str>) {
pub struct AcpService;
impl AcpService {
pub async fn prepare_session(
app_handle: tauri::AppHandle,
session_id: String,
provider_id: String,
working_dir: PathBuf,
persona_id: Option<String>,
session_store: Arc<SessionStore>,
) -> Result<(), String> {
session_store.ensure_session(&session_id, Some(provider_id.clone()));
let manager = GooseAcpManager::start(app_handle).await?;
let tauri_store = TauriStore::new(session_store, session_id.clone(), persona_id.clone());
let existing_agent_session_id = tauri_store.get_agent_session_id();
let store: Arc<dyn Store> = Arc::new(tauri_store);
manager
.prepare_session(
make_composite_key(&session_id, persona_id.as_deref()),
session_id,
provider_id,
working_dir,
existing_agent_session_id,
store,
)
.await
}
/// Send a prompt to the given ACP provider and stream the response via
/// Tauri events.
#[allow(clippy::too_many_arguments)]
@@ -97,7 +127,6 @@ impl AcpService {
None
};
let driver = AcpDriver::new(&provider_id)?;
let registry_key = make_composite_key(&session_id, persona_id.as_deref());
let cancel_token = registry.register(&registry_key, &provider_id);
@@ -143,38 +172,23 @@ impl AcpService {
prompt.clone()
};
// AcpDriver::run may use !Send futures internally, so we run it on a
// dedicated thread with a LocalSet.
let session_id_inner = session_id.clone();
let registry_inner = Arc::clone(&registry);
let join_result = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to build tokio runtime: {e}"))?;
let manager = GooseAcpManager::start(app_handle.clone()).await?;
let result = manager
.send_prompt(
registry_key.clone(),
session_id.clone(),
provider_id.clone(),
working_dir,
agent_session_id,
store,
writer,
effective_prompt,
images,
)
.await;
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
driver
.run(
&session_id_inner,
&effective_prompt,
&images,
&working_dir,
&store,
&writer,
&cancel_token,
agent_session_id.as_deref(),
)
.await
})
})
.await;
// Always deregister, even on panic/JoinError
registry_inner.deregister(&registry_key);
let result = join_result.map_err(|e| format!("ACP task panicked: {e}"))?;
registry.deregister(&registry_key);
drop(cancel_token);
if let Err(ref error) = result {
let _ = session_store.update_message(
@@ -17,6 +17,7 @@ import type {
AgentConnectionType,
CreateAgentRequest,
} from "@/shared/types/agents";
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
interface AgentConfigProps {
agent?: Agent;
@@ -25,20 +26,20 @@ interface AgentConfigProps {
onCancel: () => void;
}
const PROVIDER_OPTIONS: { value: ProviderType; label: string }[] = [
{ value: "goose", label: "Goose" },
{ value: "claude", label: "Claude" },
{ value: "openai", label: "OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom" },
];
export function AgentConfig({
agent,
personas,
onSave,
onCancel,
}: AgentConfigProps) {
const [acpProviders, setAcpProviders] = useState<AcpProvider[]>([]);
useEffect(() => {
discoverAcpProviders()
.then(setAcpProviders)
.catch(() => setAcpProviders([]));
}, []);
const [name, setName] = useState(agent?.name ?? "");
const [personaId, setPersonaId] = useState(agent?.personaId ?? "");
const connectionType: AgentConnectionType = "builtin";
@@ -109,7 +110,7 @@ export function AgentConfig({
</Label>
<Select
value={personaId || "__none__"}
onValueChange={(v) => setPersonaId(v === "__none__" ? "" : v)}
onValueChange={(v: string) => setPersonaId(v === "__none__" ? "" : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="None" />
@@ -138,15 +139,15 @@ export function AgentConfig({
</Label>
<Select
value={provider}
onValueChange={(v) => setProvider(v as ProviderType)}
onValueChange={(v: string) => setProvider(v as ProviderType)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{acpProviders.map((providerOption) => (
<SelectItem key={providerOption.id} value={providerOption.id}>
{providerOption.label}
</SelectItem>
))}
</SelectContent>
@@ -31,6 +31,7 @@ import type {
CreatePersonaRequest,
UpdatePersonaRequest,
} from "@/shared/types/agents";
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
import { AvatarDropZone } from "./AvatarDropZone";
interface PersonaEditorProps {
@@ -42,14 +43,6 @@ interface PersonaEditorProps {
isPending?: boolean;
}
const PROVIDER_OPTIONS: { value: ProviderType; label: string }[] = [
{ value: "goose", label: "Goose" },
{ value: "claude", label: "Claude" },
{ value: "openai", label: "OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom" },
];
export function PersonaEditor({
persona,
isOpen,
@@ -61,6 +54,16 @@ export function PersonaEditor({
const isEditing = !!persona;
const isReadOnly = persona?.isBuiltin ?? false;
const [acpProviders, setAcpProviders] = useState<AcpProvider[]>([]);
useEffect(() => {
if (isOpen) {
discoverAcpProviders()
.then(setAcpProviders)
.catch(() => setAcpProviders([]));
}
}, [isOpen]);
const [displayName, setDisplayName] = useState("");
const [avatar, setAvatar] = useState<Avatar | null>(null);
const [systemPrompt, setSystemPrompt] = useState("");
@@ -203,7 +206,7 @@ export function PersonaEditor({
</Label>
<Select
value={provider || "__none__"}
onValueChange={(v) =>
onValueChange={(v: string) =>
setProvider(
v === "__none__"
? ("" as ProviderType | "")
@@ -222,9 +225,9 @@ export function PersonaEditor({
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{PROVIDER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{acpProviders.map((providerOption) => (
<SelectItem key={providerOption.id} value={providerOption.id}>
{providerOption.label}
</SelectItem>
))}
</SelectContent>
+23 -8
View File
@@ -16,6 +16,7 @@ import { useProviderSelection } from "@/features/agents/hooks/useProviderSelecti
import { useChatSessionStore } from "../stores/chatSessionStore";
import { getProject, type ProjectInfo } from "@/features/projects/api/projects";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { acpPrepareSession } from "@/shared/api/acp";
import {
buildProjectSystemPrompt,
composeSystemPrompt,
@@ -77,7 +78,6 @@ export function ChatView({
? 0
: CONTEXT_PANEL_REFLOW_DURATION_MS;
// Provider state from shared store
const {
providers,
providersLoading,
@@ -85,7 +85,6 @@ export function ChatView({
setSelectedProvider: setGlobalSelectedProvider,
} = useProviderSelection();
// Persona state
const personas = useAgentStore((s) => s.personas);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
initialPersonaId ?? null,
@@ -118,7 +117,6 @@ export function ChatView({
})),
[projects],
);
// For existing sessions, use their saved provider; otherwise use global selection
const selectedProvider =
session?.providerId ??
initialProvider ??
@@ -267,6 +265,28 @@ export function ChatView({
? { id: selectedPersona.id, name: selectedPersona.displayName }
: undefined;
useEffect(() => {
let cancelled = false;
acpPrepareSession(activeSessionId, selectedProvider, {
workingDir: effectiveWorkingDir,
personaId: selectedPersonaId ?? undefined,
}).catch((error) => {
if (!cancelled) {
console.error("Failed to prepare ACP session:", error);
}
});
return () => {
cancelled = true;
};
}, [
activeSessionId,
effectiveWorkingDir,
selectedPersonaId,
selectedProvider,
]);
const {
messages,
chatState,
@@ -282,12 +302,10 @@ export function ChatView({
effectiveWorkingDir,
);
// Ref for deferred sends after persona switch (Bug 1 fix: avoid stale system prompt)
const deferredSend = useRef<{ text: string; images?: PastedImage[] } | null>(
null,
);
// Wrap sendMessage to handle @ mentioned persona overrides
const chatStore = useChatStore();
const handleSend = useCallback(
(text: string, personaId?: string, images?: PastedImage[]) => {
@@ -326,7 +344,6 @@ export function ChatView({
],
);
// Effect to send deferred message after persona switch completes
useEffect(() => {
if (deferredSend.current && selectedPersona) {
const { text, images } = deferredSend.current;
@@ -335,7 +352,6 @@ export function ChatView({
}
}, [sendMessage, selectedPersona]);
// Auto-send initial message from HomeScreen on mount
const initialMessageSent = useRef(false);
useEffect(() => {
if (
@@ -355,7 +371,6 @@ export function ChatView({
chatState === "waiting" ||
chatState === "compacting";
// Open persona editor
const handleCreatePersona = useCallback(() => {
useAgentStore.getState().openPersonaEditor();
}, []);
@@ -7,7 +7,6 @@ import {
type DoctorCheck,
type DoctorReport,
} from "@/shared/api/doctor";
import { discoverAcpProviders } from "@/shared/api/acp";
import { DoctorCheckRow } from "./DoctorCheckRow";
function formatDebugReport(report: DoctorReport): string {
@@ -66,16 +65,9 @@ export function DoctorSettings() {
}
}, []);
/** Run checks, then refresh the ACP provider list so newly-installed agents appear. */
/** Run checks (provider list is now static, no refresh needed). */
const runChecksAndRefresh = useCallback(async () => {
await runChecks();
if (mountedRef.current) {
try {
await discoverAcpProviders();
} catch (e) {
console.error("[Doctor] Failed to refresh providers:", e);
}
}
}, [runChecks]);
useEffect(() => {
+20
View File
@@ -14,6 +14,11 @@ export interface AcpSendMessageOptions {
images?: [string, string][];
}
export interface AcpPrepareSessionOptions {
workingDir?: string;
personaId?: string;
}
/** Discover ACP providers installed on the system. */
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
return invoke("discover_acp_providers");
@@ -39,6 +44,21 @@ export async function acpSendMessage(
});
}
/** Prepare or warm an ACP session ahead of the first prompt. */
export async function acpPrepareSession(
sessionId: string,
providerId: string,
options: AcpPrepareSessionOptions = {},
): Promise<void> {
const { workingDir, personaId } = options;
return invoke("acp_prepare_session", {
sessionId,
providerId,
workingDir: workingDir ?? null,
personaId: personaId ?? null,
});
}
/** Cancel an in-progress ACP session so the backend stops streaming. */
export async function acpCancelSession(
sessionId: string,
+6 -2
View File
@@ -1,5 +1,9 @@
// Provider types
export type ProviderType = "goose" | "claude" | "openai" | "ollama" | "custom";
// Provider types — these map to goose serve provider names.
// All sessions run through goose serve; the provider ID selects which
// backend provider goose uses for inference. The list is dynamic
// (fetched from the backend via discover_acp_providers) so this is a
// plain string rather than a narrow union.
export type ProviderType = string;
export interface ProviderConfig {
type: ProviderType;