mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
refactor(acp): route goose2 through goose serve (#101)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}}}"
|
||||
|
||||
Executable
+254
@@ -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
|
||||
Generated
+354
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, ¤t_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.
|
||||
|
||||
@@ -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 ¬ification.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 ¬ification.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"));
|
||||
}
|
||||
}
|
||||
@@ -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(®istry_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(®istry);
|
||||
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(®istry_key);
|
||||
|
||||
let result = join_result.map_err(|e| format!("ACP task panicked: {e}"))?;
|
||||
registry.deregister(®istry_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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user