mount acp in the goosed server to migrate using acp protocols iteratively (#9097)

This commit is contained in:
Lifei Zhou
2026-05-18 09:43:30 +10:00
committed by GitHub
parent d04c882388
commit 1fafd74413
17 changed files with 1430 additions and 61 deletions
+33 -5
View File
@@ -6,6 +6,12 @@ use axum::{
};
use subtle::ConstantTimeEq;
fn token_matches(candidate: Option<&str>, expected: &str) -> bool {
candidate
.map(|key| bool::from(key.as_bytes().ct_eq(expected.as_bytes())))
.unwrap_or(false)
}
pub async fn check_token(
State(state): State<String>,
request: Request,
@@ -24,10 +30,32 @@ pub async fn check_token(
.get("X-Secret-Key")
.and_then(|value| value.to_str().ok());
match secret_key {
Some(key) if bool::from(key.as_bytes().ct_eq(state.as_bytes())) => {
Ok(next.run(request).await)
}
_ => Err(StatusCode::UNAUTHORIZED),
if token_matches(secret_key, &state) {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
pub async fn check_acp_token(
State(state): State<String>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let header_token = request
.headers()
.get("X-Secret-Key")
.and_then(|value| value.to_str().ok());
let query_token = request.uri().query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "token")
.map(|(_, value)| value.into_owned())
});
if token_matches(header_token, &state) || token_matches(query_token.as_deref(), &state) {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
+28 -7
View File
@@ -3,9 +3,14 @@ use crate::state;
use anyhow::Result;
use axum::middleware;
use axum_server::Handle;
use goose_server::auth::check_token;
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use goose::acp::transport::create_acp_router;
use goose::agents::GoosePlatform;
use goose::config::paths::Paths;
use goose_server::auth::{check_acp_token, check_token};
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
use goose_server::tls::setup_tls;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
@@ -64,12 +69,28 @@ pub async fn run() -> Result<()> {
.allow_methods(Any)
.allow_headers(Any);
let app = crate::routes::configure(app_state.clone(), secret_key.clone())
.layer(middleware::from_fn_with_state(
secret_key.clone(),
check_token,
))
.layer(cors);
// TODO(acp-migration): When ui/desktop launches `goose serve` directly,
// move any goosed-only ACP setup into the goose serve path before deleting
// this bridge. In particular, verify everything ACP currently gets from
// goosed startup/AppState initialization, including builtin extension
// registration and the desktop platform identity.
let acp_server = Arc::new(AcpServer::new(AcpServerFactoryConfig {
builtins: vec!["developer".to_string()],
data_dir: Paths::data_dir(),
config_dir: Paths::config_dir(),
goose_platform: GoosePlatform::GooseDesktop,
additional_source_roots: Vec::new(),
}));
let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()).layer(
middleware::from_fn_with_state(secret_key.clone(), check_token),
);
let acp_router = create_acp_router(acp_server).layer(middleware::from_fn_with_state(
secret_key.clone(),
check_acp_token,
));
let app = rest_router.merge(acp_router).layer(cors);
let addr = settings.socket_addr();
+6 -1
View File
@@ -33,7 +33,12 @@ impl GooseAcpAgent {
pub(super) async fn on_get_extensions(
&self,
) -> Result<GetExtensionsResponse, agent_client_protocol::Error> {
let extensions = crate::config::extensions::get_all_extensions();
let extensions = crate::config::extensions::get_all_extensions()
.into_iter()
.filter(|ext| {
!crate::agents::extension_manager::is_hidden_extension(&ext.config.name())
})
.collect::<Vec<_>>();
let warnings = crate::config::extensions::get_warnings();
let extensions_json = extensions
.into_iter()
+19 -9
View File
@@ -94,10 +94,8 @@ async fn health() -> &'static str {
"ok"
}
pub fn create_router(server: Arc<AcpServer>, secret_key: String) -> Router {
let registry = Arc::new(connection::ConnectionRegistry::new(server));
let cors = CorsLayer::new()
fn acp_cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_headers([
@@ -113,14 +111,26 @@ pub fn create_router(server: Arc<AcpServer>, secret_key: String) -> Router {
.expose_headers([
HeaderName::from_static("acp-connection-id"),
HeaderName::from_static("acp-session-id"),
]);
])
}
fn create_acp_routes(server: Arc<AcpServer>) -> Router {
let registry = Arc::new(connection::ConnectionRegistry::new(server));
Router::new()
.route("/health", get(health))
.route("/status", get(health))
.route("/acp", post(http::handle_post).with_state(registry.clone()))
.route("/acp", get(handle_get).with_state(registry.clone()))
.route("/acp", delete(http::handle_delete).with_state(registry))
.merge(super::mcp_app_proxy::routes(secret_key))
.layer(cors)
}
pub fn create_acp_router(server: Arc<AcpServer>) -> Router {
create_acp_routes(server).layer(acp_cors_layer())
}
pub fn create_router(server: Arc<AcpServer>, secret_key: String) -> Router {
create_acp_routes(server)
.route("/health", get(health))
.route("/status", get(health))
.merge(super::mcp_app_proxy::routes(secret_key))
.layer(acp_cors_layer())
}
+18 -8
View File
@@ -1,8 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://127.0.0.1:* https:; object-src 'none'; font-src 'self' data: https:; media-src 'self' mediastream:; form-action 'none'; base-uri 'self'; manifest-src 'self'; worker-src 'self'; frame-src 'self' https: http:;"/>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://127.0.0.1:* https: ws: wss:; object-src 'none'; font-src 'self' data: https:; media-src 'self' mediastream:; form-action 'none'; base-uri 'self'; manifest-src 'self'; worker-src 'self'; frame-src 'self' https: http:;"
/>
<title>Goose</title>
<script>
// Initialize theme before any content loads
@@ -13,8 +16,12 @@
const useSystemTheme = localStorage.getItem('use_system_theme') === 'true';
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme');
const isDark = useSystemTheme ? systemPrefersDark : (savedTheme ? savedTheme === 'dark' : systemPrefersDark);
const isDark = useSystemTheme
? systemPrefersDark
: savedTheme
? savedTheme === 'dark'
: systemPrefersDark;
if (isDark) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
@@ -33,7 +40,10 @@
}
}
} catch (error) {
console.warn('Failed to initialize theme from localStorage, using system preference:', error);
console.warn(
'Failed to initialize theme from localStorage, using system preference:',
error
);
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (systemPrefersDark) {
document.documentElement.classList.add('dark');
@@ -47,16 +57,16 @@
// Run immediately
initializeTheme();
// Retry after DOM is ready if initial attempt failed
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
setTimeout(initializeTheme, 50);
});
}
})();
</script>
<link href="./src/styles/main.css" rel="stylesheet"/>
<link href="./src/styles/main.css" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+2
View File
@@ -9,6 +9,7 @@
},
"main": ".vite/build/main.js",
"scripts": {
"postinstall": "pnpm --filter @aaif/goose-sdk run build",
"typecheck": "tsc --noEmit",
"generate-api": "openapi-ts",
"start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
@@ -46,6 +47,7 @@
},
"dependencies": {
"@aaif/goose-sdk": "workspace:*",
"@agentclientprotocol/sdk": "^0.19.0",
"@mcp-ui/client": "6.1.0",
"@modelcontextprotocol/ext-apps": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.12",
@@ -0,0 +1,380 @@
# ACP First Spike: Configured Extensions List
## Purpose
Use configured extensions as the first `ui/desktop` REST-to-ACP migration slice.
This spike is intentionally smaller than chat or sessions. It proves that desktop can talk directly to `/acp` through `goosed` during migration, without depending on ACP session ID behavior or chat streaming semantics.
## Scope
Migrate the read-only configured extensions list behind a feature flag.
```text
Current REST:
GET /config/extensions
Target ACP:
_goose/config/extensions
```
Do not migrate add/update/delete in this first slice. Those can remain REST until the read path and ACP client plumbing are proven.
## Why This Slice
- No session ID dependency.
- No chat streaming or prompt lifecycle.
- No tool approval.
- No message-shape conversion.
- Exercises direct renderer WebSocket to `/acp`.
- Exercises ACP initialization and custom `_goose/*` request dispatch.
- Easy to compare REST and ACP responses.
## Relevant Existing Code
Desktop REST read path:
- `ui/desktop/src/components/ConfigContext.tsx`
- imports `getExtensions as apiGetExtensions`
- `refreshExtensions()` calls `apiGetExtensions()`
- initial provider load also calls `apiGetExtensions()`
Desktop UI consumer:
- `ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx`
- consumes `extensionsList` from `useConfig()`
- calls `getExtensions(true)` to refresh
REST backend:
- `crates/goose-server/src/routes/config_management.rs`
- `GET /config/extensions`
- `POST /config/extensions`
- `DELETE /config/extensions/{name}`
ACP backend:
- `crates/goose/src/acp/server/extensions.rs`
- `_goose/config/extensions`
- `_goose/config/extensions/add`
- `_goose/config/extensions/remove`
- `_goose/config/extensions/toggle`
ACP client reference:
- `ui/goose2/src/shared/api/createWebSocketStream.ts`
- `ui/goose2/src/shared/api/acpConnection.ts`
- `ui/goose2/src/shared/api/acpApi.ts`
Treat `ui/goose2` as a reference only. Do not share runtime code with it because `ui/goose2` is expected to move out of this repo.
## Current Gap Found
REST `GET /config/extensions` filters hidden extensions:
```rust
goose::config::get_all_extensions()
.into_iter()
.filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name()))
```
ACP `_goose/config/extensions` currently calls `crate::config::extensions::get_all_extensions()` and does not apply the same hidden-extension filter.
Recommendation: fix ACP to match REST before enabling this feature flag. The goal is to prove the migration path without introducing UI-visible behavior differences.
## Backend Plan
## Step-By-Step Review Plan
Work in small reviewable slices. After each step, stop and review before moving to the next one.
### Step 1: Inspect router/auth shape
Goal: confirm the exact `goosed` router composition and ACP transport shape before editing.
Review points:
- where REST `X-Secret-Key` middleware is applied
- whether ACP can be mounted as a separate branch
- whether `goose::acp::transport::create_router` causes route collisions
- where ACP token auth should live
- whether Cargo dependencies already allow `goose-server` to call ACP router code
Expected outcome: a precise patch plan for backend mounting.
### Step 2: Add ACP-only router helper
Goal: avoid route collisions by exposing only `/acp` routes for embedding in `goosed`.
Likely file:
- `crates/goose/src/acp/transport/mod.rs`
Reason: existing `create_router(...)` includes `/health`, `/status`, and MCP app proxy routes. `goosed` already owns some of those routes.
Expected outcome: a helper such as `create_acp_router(...)` or equivalent that only mounts:
```text
/acp POST
/acp GET
/acp DELETE
```
### Step 3: Mount `/acp` in `goosed`
Goal: serve REST and ACP from the same `goosed` process during migration.
Likely files:
- `crates/goose-server/src/commands/agent.rs`
- maybe `crates/goose-server/src/routes/mod.rs`
Expected shape:
```text
goosed
REST routes protected by X-Secret-Key
/acp protected by ACP token auth
```
### Step 4: Expose ACP URL/token to renderer
Goal: let renderer connect directly to `/acp` over WebSocket.
Likely files:
- `ui/desktop/src/main.ts`
- `ui/desktop/src/preload.ts`
- related Electron type declarations if present
Expected renderer-facing value:
```text
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
### Step 5: Add minimal desktop ACP client
Goal: create enough client plumbing to initialize ACP and call one custom method.
Suggested files:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
Reference, not shared dependency:
- `ui/goose2/src/shared/api/createWebSocketStream.ts`
- `ui/goose2/src/shared/api/acpConnection.ts`
- `ui/goose2/src/shared/api/acpApi.ts`
### Step 6: Migrate configured extensions read path behind a flag
Goal: call `_goose/config/extensions` for read-only extension listing when enabled.
Primary integration file:
- `ui/desktop/src/components/ConfigContext.tsx`
Keep writes on REST in this slice:
- add extension
- remove extension
- toggle extension
- bundled extension sync/prune
### Step 7: Validate parity
Goal: prove ACP and REST return equivalent visible extension data.
Validation checklist is below.
## Backend Details
### Mount `/acp` in `goosed`
Add the ACP Axum router to `goosed agent`.
Likely files:
- `crates/goose-server/src/commands/agent.rs`
- `crates/goose-server/src/routes/mod.rs`
Use existing ACP pieces:
```rust
goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}
goose::acp::transport::create_router
```
Keep REST and ACP as separate route branches.
Watch for route collisions:
- `/status`
- `/mcp-app-proxy`
- `/mcp-app-guest`
### Add ACP token auth
The renderer should connect through direct WebSocket:
```text
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
Do not put `/acp` behind the REST `X-Secret-Key` middleware because browser WebSocket cannot set arbitrary headers.
Guardrails:
- Use a random ACP token for the `goosed` process.
- Prefer an ACP-specific token over reusing the raw REST `X-Secret-Key`.
- Never log full `/acp?token=...` URLs.
- Accept ACP token auth only for the local desktop backend.
- Keep REST `X-Secret-Key` for REST routes during migration.
### Fix `_goose/config/extensions` parity
Update ACP `_goose/config/extensions` to match REST behavior:
- filter hidden extensions
- preserve warnings
- preserve the response shape needed by desktop
The ACP response currently injects a `config_key` field. Verify the desktop shape expected by `ExtensionEntry` and normalize on the client if needed.
## Desktop Plan
### Add ACP client files
Suggested files:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
Base these on `ui/goose2`, but adapt to Electron.
Differences from `ui/goose2`:
- URL lookup should use Electron, not Tauri.
- Build ACP URL from the existing `goosed` host.
- Append the ACP token.
- Initialize ACP once and reconnect on close.
Example URL shape:
```ts
const baseUrl = await window.electron.getGoosedHostPort();
const acpUrl = baseUrl.replace(/^http/, 'ws') + '/acp?token=' + encodeURIComponent(token);
```
If `goosed` is running HTTPS, this becomes `wss://.../acp?...`.
### Add ACP extensions API wrapper
Add a wrapper such as:
```ts
getConfigExtensionsViaAcp(): Promise<ExtensionResponse>
```
It should call:
```text
_goose/config/extensions
```
Normalize the response to the current desktop `ExtensionResponse` shape:
```ts
type ExtensionResponse = {
extensions: ExtensionEntry[];
warnings: string[];
};
```
### Add a feature flag
Suggested name:
```text
acpConfigExtensions
```
The exact flag mechanism should follow existing desktop feature-flag conventions if present. If there is no suitable framework, use a local env/config gate for the spike.
### Route read path through ACP only when enabled
Primary integration file:
- `ui/desktop/src/components/ConfigContext.tsx`
Candidate call sites:
- `refreshExtensions()`
- initial load effect that fetches extensions after bundled sync
Keep these mutation paths on REST for the first spike:
- `addExtension`
- `removeExtension`
- `toggleExtension`
- bundled extension sync/prune
After REST mutations complete, refresh can use ACP when the flag is enabled.
### Add fallback behavior
For the spike, if ACP connection or `_goose/config/extensions` fails, log the error and fall back to REST.
This fallback is only for the spike. Once a feature area is fully migrated, the matching REST endpoint should be removed.
## Validation Checklist
Compare REST and ACP for the same local config:
- same visible extension count
- same names
- same `enabled` values
- same extension types (`builtin`, `stdio`, `streamable_http`, etc.)
- same warnings
- hidden extensions do not appear
- settings/extensions UI renders correctly
- add/update/delete still work through REST and refresh the list
- failed ACP connection falls back to REST while the spike flag is enabled
## Removal Rule
Do not remove `GET /config/extensions` after this first read-only spike if writes still use REST sync logic that depends on the endpoint.
Remove the REST extension endpoints only after the full extension config surface has migrated:
```text
GET /config/extensions
POST /config/extensions
DELETE /config/extensions/{name}
```
Corresponding ACP methods:
```text
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
```
## Done Criteria
- `goosed` exposes token-authenticated `/acp`.
- `ui/desktop` can open a direct renderer WebSocket to `/acp`.
- ACP initialize succeeds.
- `_goose/config/extensions` returns REST-equivalent visible extension data.
- Feature flag can switch extension list reads between REST and ACP.
- Existing extension settings UI behaves the same under the ACP read path.
+707
View File
@@ -0,0 +1,707 @@
# Desktop ACP Migration Spike
## Goal
`ui/desktop` currently talks to `goosed agent` through REST/OpenAPI. We want to migrate the desktop app to talk to Goose through ACP, while keeping the migration gradual and avoiding a large bundle-size increase.
The preferred migration direction is:
- Keep bundling only `goosed` during migration to avoid shipping both large binaries.
- Add ACP serving at `/acp` inside `goosed agent` as a temporary bridge.
- Let `ui/desktop` talk directly to `/acp` for migrated surfaces.
- Keep existing REST routes only for unmigrated areas.
- Once all REST behavior has moved to ACP, stop bundling/running `goosed` and switch desktop to the existing `goose serve` ACP server.
## Current Architecture
### Desktop backend
`goosed` is an Axum HTTP server.
Relevant files:
- `crates/goose-server/src/main.rs`
- `crates/goose-server/src/commands/agent.rs`
- `crates/goose-server/src/routes/mod.rs`
- `ui/desktop/src/goosed.ts`
`ui/desktop/src/goosed.ts` finds and spawns the bundled `goosed` binary:
```ts
const spawnCommand = goosedPath;
const spawnArgs = ['agent'];
```
The renderer configures the generated OpenAPI client against the `goosed` URL in `ui/desktop/src/renderer.tsx`.
### Desktop REST usage
`ui/desktop` imports generated API methods from `ui/desktop/src/api`.
Important chat/session paths today:
- `ui/desktop/src/sessions.ts`
- `startAgent` creates sessions.
- `ui/desktop/src/hooks/useChatStream.ts`
- `resumeAgent`
- `sessionReply`
- `sessionCancel`
- `getSession`
- `updateFromSession`
- `ui/desktop/src/hooks/useSessionEvents.ts`
- opens `GET /sessions/{id}/events` as SSE.
The current REST streaming model is Goose-specific:
- `POST /sessions/{id}/reply`
- `GET /sessions/{id}/events`
- event types such as `Message`, `Finish`, `Error`, `Notification`, `ActiveRequests`
- request routing through `request_id` / `chat_request_id`
## Existing ACP Implementation
Goose already has ACP support.
Relevant files:
- ACP agent implementation: `crates/goose/src/acp/server.rs`
- ACP transport: `crates/goose/src/acp/transport/mod.rs`
- ACP custom methods: `crates/goose/src/acp/server/custom_dispatch.rs`
- Custom request types: `crates/goose-sdk/src/custom_requests.rs`
- CLI entry points: `crates/goose-cli/src/cli.rs`
There are two existing ACP modes:
### `goose acp`
Runs ACP over stdio:
```rust
Some(Command::Acp { builtins }) => goose::acp::server::run(builtins).await
```
This is standard ACP, but in Electron it would require the main process to own stdio and bridge to the renderer.
### `goose serve`
Runs ACP over HTTP/SSE/WebSocket:
```rust
Some(Command::Serve { host, port, builtins }) => handle_serve_command(host, port, builtins).await
```
The transport router registers:
```rust
/health
/status
/acp POST
/acp GET
/acp DELETE
```
`GET /acp` upgrades to WebSocket when requested. Otherwise it behaves as SSE. `POST /acp` accepts JSON-RPC messages.
## Bundle Size Finding
Local release binary sizes:
```text
target/release/goose 230M
target/release/goosed 218M
```
Bundling both would add roughly:
```text
goosed + goose = 448M uncompressed
```
`ui/desktop/src/bin` currently contains both binaries locally:
```text
ui/desktop/src/bin/goose 230M
ui/desktop/src/bin/goosed 218M
```
This is too large as a long-term plan.
## Recommended Backend Strategy
Do not bundle both `goose` and `goosed` for production migration. The temporary bridge is to mount `/acp` into `goosed`; the final backend is `goose serve`.
Migration backend:
1. Add ACP serving to `goosed agent`.
2. Keep REST routes available on the same process during migration.
3. Mount `/acp` in the existing `goosed` Axum app.
4. Gradually migrate desktop feature areas from REST to ACP.
5. Remove each relevant REST endpoint once its feature area is migrated.
Migration shape:
```text
ui/desktop renderer
-> http(s)://127.0.0.1:<port>/acp
goosed process
existing REST routes temporarily
ACP /acp
```
This keeps the backend bundle around current `goosed` size plus a small ACP wiring delta, instead of adding a second 230M binary.
Final backend:
```text
ui/desktop renderer
-> ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
goose serve
standard ACP methods
_goose/* custom ACP methods
```
In the final state `goosed` is not bundled or spawned by the desktop app.
Before deleting the `goosed` bridge, verify that the final `goose serve` path has the same
desktop-specific ACP behavior that the bridge depended on during migration. In particular:
- config and data directories match the desktop app's expected Goose paths
- builtin extension setup matches what desktop needs
- ACP initialization uses the correct desktop platform identity
- any state initialized today by `goosed` startup is either no longer needed or is initialized by
the final desktop `goose serve` launch path
## Migration Bridge vs Final Server
Mounting `/acp` in `goosed` should be treated as a bridge, not the destination.
```text
During migration:
ui/desktop
-> REST for unmigrated features
-> /acp for migrated features
bundled backend:
goosed
REST routes
temporary /acp route
```
```text
After migration:
ui/desktop
-> /acp only
bundled backend:
goose serve
standard ACP
_goose/* custom ACP methods
```
The migration rule is:
```text
When a feature moves to /acp:
remove its corresponding REST endpoint from goosed
```
The final cutover from `goosed agent` to `goose serve` is blocked until no desktop runtime feature depends on REST/OpenAPI.
Expected effort:
- Mounting `/acp` in `goosed`: relatively easy because both servers are Axum and the ACP router already exists.
- Removing `goosed` later: medium effort, because every REST-only desktop capability must first be moved into standard ACP or `_goose/*` custom ACP methods.
## Alternative Considered: Move REST Into `goose serve`
It is possible to make `goose serve` also mount `goose-server` REST routes. That would make `goose-cli` depend on `goose-server`.
Tradeoffs:
- Simpler single `goose` binary packaging.
- But the general-purpose CLI binary gets desktop REST/OpenAPI/tunnel/gateway/server dependencies.
- Likely increases `goose` binary size.
- Blurs the CLI and desktop backend boundary.
The cleaner migration bridge is the reverse: add ACP to `goosed` temporarily. The final target remains `goose serve`, not `goosed`.
## Streaming Differences
Adding `/acp` to `goosed` does not make the current desktop streaming code work unchanged.
### Current REST streaming
The current desktop chat stream expects:
- `GET /sessions/{id}/events`
- Goose-specific `MessageEvent` objects
- `ActiveRequests`
- `request_id` / `chat_request_id`
- `Message`, `Finish`, `Error`, `Notification`
### ACP streaming
ACP streaming is protocol-level:
- Client sends JSON-RPC `session/prompt`.
- Server emits `session/update` notifications.
- Updates include:
- agent message chunks
- user message chunks
- tool calls
- tool call updates
- usage updates
- session info updates
- config option updates
Tool approval also changes:
- REST uses `/action-required/tool-confirmation`.
- ACP sends `RequestPermissionRequest`.
- The client must respond on the ACP connection.
## Recommended Client Strategy
Use WebSocket ACP directly from the renderer.
Preferred shape:
```text
renderer -> wss://127.0.0.1:<port>/acp
send initialize
send session/new
send session/load
send session/prompt
receive session/update notifications continuously
respond to permission requests
```
WebSocket is preferable to HTTP POST + SSE because it gives one bidirectional connection for requests, responses, notifications, and permission responses. Do not add an Electron-main IPC transport layer for normal ACP chat traffic.
## Existing Reference: `ui/goose2`
`ui/goose2` already has a client pattern that can be reused or adapted.
Relevant files:
- `ui/goose2/src/shared/api/createWebSocketStream.ts`
- `ui/goose2/src/shared/api/acpConnection.ts`
- `ui/goose2/src/shared/api/acpApi.ts`
- `ui/goose2/src-tauri/src/services/acp/goose_serve.rs`
`ui/goose2`:
- gets a `/acp` WebSocket URL from Tauri
- creates a WebSocket stream
- creates a `GooseClient`
- initializes ACP with client capabilities
- routes `sessionUpdate` notifications through a handler
- exposes APIs such as:
- `listSessions`
- `newSession`
- `loadSession`
- `prompt`
- `cancelSession`
- `setProvider`
- `setModel`
- `_goose/*` custom methods
`ui/desktop` can use the same pattern, replacing Tauri URL lookup with Electron URL lookup.
Because `ui/goose2` is expected to move out of this repo in the future, desktop should not share runtime code with it. Treat `ui/goose2` as a reference implementation and copy/adapt the small ACP client pieces into `ui/desktop`.
Example URL derivation:
```ts
const baseUrl = await window.electron.getGoosedHostPort();
const acpUrl = baseUrl.replace(/^http/, 'ws') + '/acp';
```
If `goosed` is running HTTPS, this becomes `wss://.../acp`.
## ACP Auth Decision
Current `goosed` REST uses `X-Secret-Key`.
Browser WebSocket does not support arbitrary request headers. If `/acp` is mounted behind the same auth middleware, direct renderer WebSocket may fail.
Chosen direction: `/acp` should have ACP-compatible token auth.
During migration:
```text
REST routes:
X-Secret-Key header
ACP route:
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
After REST is removed:
```text
ACP only:
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
This preserves a security boundary for the long-term desktop API while still allowing direct renderer WebSocket connections.
Guardrails:
- Use a random ACP token for the `goosed` process.
- Prefer an ACP-specific token over reusing the raw REST `X-Secret-Key`.
- Never log full `/acp?token=...` URLs.
- Keep accepting `X-Secret-Key` only for REST during migration.
- Accept ACP token auth only for the local desktop backend.
- Keep REST and ACP as separate route branches so auth policy and endpoint removal stay clear.
Alternatives considered:
1. Mount `/acp` outside `X-Secret-Key` auth, relying on localhost binding.
2. Allow auth through a query parameter for `/acp`, for example `/acp?token=...`.
3. Open the WebSocket from Electron main, where headers are easier, and bridge to renderer via IPC.
4. Use HTTP/SSE transport where headers are possible, though this is less ergonomic for ACP permission/request-response flow.
Option 2 is the preferred approach. Option 1 matches current `ui/goose2` behavior, but it is weaker as the final desktop backend shape.
## Migration Routing
Keep REST and ACP side-by-side only for feature areas that have not moved yet.
Example shape:
```ts
const backend = {
sessions: flags.acpSessions ? acpSessions : restSessions,
chat: flags.acpChat ? acpChat : restChat,
providers: flags.acpProviders ? acpProviders : restProviders,
};
```
Rules:
- `goosed` remains default for unmigrated surfaces.
- ACP is opt-in per feature area.
- New functionality should prefer ACP unless blocked.
- Once a feature area is migrated to `/acp`, remove the corresponding REST endpoint from `goosed` rather than keeping a permanent fallback.
- Any missing behavior discovered during migration should be added to ACP before removing the REST endpoint.
- Final milestone is no runtime dependency on generated REST APIs.
## Migration Order
### 1. Mount `/acp` in `goosed`
Add ACP Axum router to the `goosed agent` app.
Likely places:
- `crates/goose-server/src/commands/agent.rs`
- `crates/goose-server/src/routes/mod.rs`
Use:
```rust
goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}
goose::acp::transport::create_router
```
Need to decide:
- route merge order
- whether ACP MCP app proxy should be included once or deduplicated against existing goosed MCP app proxy routes
Auth model: use token-authenticated `/acp` for direct renderer WebSocket, separate from REST `X-Secret-Key`.
### 2. Add desktop ACP client
Add something like:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
This can be based on `ui/goose2`.
### 3. Migrate session list/create/load
Map:
```text
REST /agent/start -> ACP session/new
REST /agent/resume -> ACP session/load
REST /sessions -> ACP session/list
REST /sessions/{id} -> ACP session/load plus replay/session metadata
```
This proves:
- session IDs
- history replay
- session metadata
- current model/provider/mode state
### 4. Migrate chat streaming
Map:
```text
REST /sessions/{id}/reply
REST /sessions/{id}/events
-> ACP session/prompt + session/update notifications
```
Prefer moving the chat state toward ACP-native events and data structures rather than preserving the old goosed `MessageEvent` model. A temporary adapter into existing desktop `Message` and `TokenState` shapes is acceptable only as a short bridge if it substantially lowers rollout risk.
### 5. Migrate tool approval and tool display
Map:
```text
REST /action-required/tool-confirmation
-> ACP RequestPermissionRequest response
```
Tool display should move toward ACP-native `tool_call` / `tool_call_update` state. Any old desktop message-shape adapter should be treated as temporary migration glue.
### 6. Migrate provider/model/mode
Use ACP session config options:
```text
setSessionConfigOption({ configId: "provider" })
setSessionConfigOption({ configId: "model" })
setSessionConfigOption({ configId: "mode" })
```
Use existing Goose custom methods for provider inventory and setup:
```text
_goose/providers/list
_goose/providers/config/read
_goose/providers/config/save
_goose/providers/config/status
_goose/providers/custom/*
_goose/providers/catalog/*
```
### 7. Migrate extensions/tools/resources
Existing custom ACP methods cover:
```text
_goose/extensions/add
_goose/extensions/remove
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
_goose/session/extensions
_goose/tools
_goose/tool/call
_goose/resource/read
_goose/working_dir/update
```
### 8. Migrate settings and secondary surfaces
Existing ACP custom methods cover many settings/product surfaces:
```text
_goose/preferences/*
_goose/defaults/*
_goose/onboarding/import/*
_goose/sources/*
_goose/dictation/*
```
## First Spike Plan: Configured Extensions List
Before the ACP session-list PR lands, use configured extensions as the first migration slice. This avoids session ID semantics and chat streaming complexity while still proving the core `/acp` path.
### Scope
Migrate the read-only configured extensions list behind a feature flag.
```text
Current REST:
GET /config/extensions
Target ACP:
_goose/config/extensions
```
Do not migrate add/update/delete in this first slice. Those can remain REST until the read path and ACP client plumbing are proven.
### Why This Is A Good First Slice
- No session ID dependency.
- No chat streaming or prompt lifecycle.
- No tool approval.
- No message-shape conversion.
- Exercises direct renderer WebSocket to `/acp`.
- Exercises ACP initialization and custom `_goose/*` request dispatch.
- Easy to compare REST and ACP responses.
### Current Gap Found
REST `GET /config/extensions` filters hidden extensions:
```rust
goose::config::get_all_extensions()
.into_iter()
.filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name()))
```
ACP `_goose/config/extensions` currently calls `crate::config::extensions::get_all_extensions()` and does not apply the same hidden-extension filter.
For this spike, either:
1. Fix ACP to match REST before enabling the feature flag, or
2. Allow the mismatch only in local spike mode and track it as the first ACP gap.
Recommendation: fix ACP to match REST. The goal is to prove migration, not introduce UI-visible behavior differences.
### Backend Steps
1. Mount `/acp` in `goosed agent`.
- Add or merge the ACP Axum router into `crates/goose-server`.
- Keep REST and ACP as separate route branches.
- Avoid collisions with existing `/status`, `/mcp-app-proxy`, and `/mcp-app-guest` routes.
2. Add ACP token auth for `/acp`.
- Direct renderer WebSocket should connect with:
```text
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
- Do not put `/acp` behind REST `X-Secret-Key` middleware.
- Do not log full token-bearing URLs.
3. Fix `_goose/config/extensions` parity.
- Apply the same hidden-extension filtering as REST.
- Preserve `warnings`.
- Preserve enough shape for existing desktop extension rendering.
### Desktop Client Steps
1. Add ACP client files under `ui/desktop/src/acp/`.
Suggested files:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
2. Base these on `ui/goose2`, but copy/adapt rather than share.
- Replace Tauri URL lookup with Electron lookup.
- Build ACP URL from the existing goosed host.
- Append the ACP token.
- Initialize ACP once and reconnect on close.
3. Add an ACP extensions API wrapper:
```ts
getConfigExtensionsViaAcp(): Promise<ExtensionResponse>
```
It should call:
```text
_goose/config/extensions
```
and normalize the response to the existing desktop `ExtensionResponse` shape.
4. Add a feature flag.
Suggested name:
```text
acpConfigExtensions
```
5. Route only the read path through ACP when the flag is enabled.
Primary integration point:
- `ui/desktop/src/components/ConfigContext.tsx`
Keep mutation paths on REST for this first spike:
- `addExtension`
- `removeExtension`
- `toggleExtension`
- bundled extension sync/prune
### Validation
Compare REST and ACP for the same local config:
- same visible extension count
- same names
- same `enabled` values
- same extension types (`builtin`, `stdio`, `streamable_http`, etc.)
- same warnings
- hidden extensions do not appear
- settings/extensions UI renders correctly
- add/update/delete still work through REST and refresh the list
- failed ACP connection falls back to REST while the spike flag is enabled
### Removal Rule
Do not remove `GET /config/extensions` after this first read-only spike if writes still use REST sync logic that depends on the endpoint.
Remove the REST extension endpoints only after the full extension config surface has migrated:
```text
GET /config/extensions
POST /config/extensions
DELETE /config/extensions/{name}
```
Corresponding ACP methods:
```text
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
```
## Known Gaps To Investigate
Likely REST-only or partially covered areas:
- recipe encode/decode/scan/schedule/create-from-session
- schedules
- local inference model management/downloads
- tunnel/gateway
- diagnostics/system info
- telemetry
- session sharing
- app export/import/list app flows
- MCP UI proxy details
- current `ActiveRequests` reattach semantics
During the transition these can remain on REST fallback. The final target is to expose each required capability through Goose custom ACP methods under `_goose/...` unless it maps cleanly to standard ACP.
## Recommended End State
Short term:
```text
goosed exposes REST + temporary /acp
desktop uses REST by default, ACP by feature flag
```
Migration:
```text
desktop moves one feature area at a time to ACP
missing backend behavior is added as standard ACP use or _goose custom methods
matching goosed REST endpoints are removed as each feature migrates
```
End state:
```text
desktop talks to /acp directly
goose serve is the single bundled desktop backend
goosed is no longer bundled or spawned
REST/OpenAPI is removed from desktop runtime behavior
```
## Open Decisions
No major architecture decisions remain from this spike. Implementation details still need validation around route merge order, MCP app proxy deduplication, and exact token plumbing.
+93
View File
@@ -0,0 +1,93 @@
import {
DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
GooseClient,
type Client,
type GooseInitializeRequest,
} from '@aaif/goose-sdk';
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
import packageJson from '../../package.json';
import { createWebSocketStream } from './createWebSocketStream';
let clientPromise: Promise<GooseClient> | null = null;
let resolvedClient: GooseClient | null = null;
function createClientCallbacks(): () => Client {
return () => ({
requestPermission: async () => {
return {
outcome: {
outcome: 'cancelled',
},
};
},
sessionUpdate: async () => {},
});
}
function monitorConnection(client: GooseClient): void {
client.closed
.then(() => {
resolvedClient = null;
clientPromise = null;
})
.catch(() => {
resolvedClient = null;
clientPromise = null;
});
}
async function initializeConnection(): Promise<GooseClient> {
const wsUrl = await window.electron.getAcpUrl();
if (!wsUrl) {
throw new Error('ACP URL is not available');
}
const stream = createWebSocketStream(wsUrl);
const client = new GooseClient(createClientCallbacks(), stream);
await client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
_meta: {
goose: {
mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
},
},
},
clientInfo: {
name: packageJson.name,
version: packageJson.version,
},
} satisfies GooseInitializeRequest);
monitorConnection(client);
return client;
}
export async function getAcpClient(): Promise<GooseClient> {
if (resolvedClient) {
return resolvedClient;
}
if (!clientPromise) {
clientPromise = initializeConnection()
.then((client) => {
resolvedClient = client;
return client;
})
.catch((error) => {
clientPromise = null;
throw error;
});
}
return clientPromise;
}
export function getAcpClientSync(): GooseClient | null {
return resolvedClient;
}
export function isAcpClientReady(): boolean {
return resolvedClient !== null;
}
@@ -0,0 +1,77 @@
import type { Stream } from '@aaif/goose-sdk';
export function createWebSocketStream(wsUrl: string): Stream {
const ws = new window.WebSocket(wsUrl);
const incoming: unknown[] = [];
const waiters: Array<() => void> = [];
let closed = false;
function pushMessage(message: unknown): void {
incoming.push(message);
waiters.shift()?.();
}
function waitForMessage(): Promise<void> {
if (incoming.length > 0 || closed) {
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
}
const openPromise = new Promise<void>((resolve, reject) => {
ws.addEventListener('open', () => resolve(), { once: true });
ws.addEventListener('error', () => reject(new Error('ACP WebSocket connection failed')), {
once: true,
});
});
ws.addEventListener('message', (event) => {
if (typeof event.data !== 'string') {
return;
}
try {
pushMessage(JSON.parse(event.data));
} catch {
// Ignore malformed messages from the transport.
}
});
const closeWaiters = () => {
closed = true;
for (const waiter of waiters) {
waiter();
}
waiters.length = 0;
};
ws.addEventListener('close', closeWaiters);
ws.addEventListener('error', closeWaiters);
const readable = new window.ReadableStream({
async pull(controller) {
await waitForMessage();
while (incoming.length > 0) {
controller.enqueue(incoming.shift());
}
if (closed && incoming.length === 0) {
controller.close();
}
},
});
const writable = new window.WritableStream({
async write(message) {
await openPromise;
ws.send(JSON.stringify(message));
},
close() {
ws.close();
},
abort() {
ws.close();
},
});
return { readable, writable } as Stream;
}
+11
View File
@@ -0,0 +1,11 @@
import type { ExtensionResponse, ExtensionEntry } from '../api';
import { getAcpClient } from './acpConnection';
export async function getConfiguredExtensions(): Promise<ExtensionResponse> {
const client = await getAcpClient();
const response = await client.goose.GooseConfigExtensions({});
return {
extensions: response.extensions as ExtensionEntry[],
warnings: response.warnings,
};
}
+11 -31
View File
@@ -4,17 +4,16 @@ import {
readConfig,
removeConfig,
upsertConfig,
getExtensions as apiGetExtensions,
addExtension as apiAddExtension,
removeExtension as apiRemoveExtension,
providers,
} from '../api';
import { getConfiguredExtensions } from '../acp/extensions';
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
import type {
ConfigResponse,
UpsertConfigQuery,
ConfigKeyQuery,
ExtensionResponse,
ProviderDetails,
ExtensionQuery,
ExtensionConfig,
@@ -48,14 +47,6 @@ interface ConfigProviderProps {
children: React.ReactNode;
}
export class MalformedConfigError extends Error {
constructor() {
super('Check contents of ~/.config/goose/config.yaml');
this.name = 'MalformedConfigError';
Object.setPrototypeOf(this, MalformedConfigError.prototype);
}
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
@@ -114,22 +105,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
);
const refreshExtensions = useCallback(async () => {
const result = await apiGetExtensions();
if (result.response.status === 422) {
throw new MalformedConfigError();
}
if (result.error && !result.data) {
console.error(result.error);
return extensionsList;
}
const extensionResponse: ExtensionResponse = result.data!;
setExtensionsList(extensionResponse.extensions);
setExtensionWarnings(extensionResponse.warnings || []);
return extensionResponse.extensions;
}, [extensionsList]);
const { extensions, warnings } = await getConfiguredExtensions();
setExtensionsList(extensions);
setExtensionWarnings(warnings || []);
return extensions;
}, []);
const addExtension = useCallback(
async (name: string, config: ExtensionConfig, enabled: boolean) => {
@@ -212,8 +192,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
// Load extensions
try {
const extensionsResponse = await apiGetExtensions();
let extensions = extensionsResponse.data?.extensions || [];
const extensionsResponse = await getConfiguredExtensions();
let extensions = extensionsResponse.extensions;
// Always sync bundled extensions from bundled-extensions.json
// This ensures:
@@ -235,11 +215,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
await syncBundledExtensions(extensions, addExtensionForSync);
// Reload extensions after sync
const refreshedResponse = await apiGetExtensions();
extensions = refreshedResponse.data?.extensions || [];
const refreshedResponse = await getConfiguredExtensions();
extensions = refreshedResponse.extensions;
setExtensionsList(extensions);
setExtensionWarnings(extensionsResponse.data?.warnings || []);
setExtensionWarnings(extensionsResponse.warnings || []);
} catch (error) {
console.error('Failed to load extensions:', error);
}
+21
View File
@@ -723,6 +723,14 @@ const getServerSecret = (settings: Settings): string => {
return GENERATED_SECRET;
};
const buildAcpWebSocketUrl = (baseUrl: string, token: string): string => {
const url = new URL(baseUrl);
url.pathname = `${url.pathname.replace(/\/+$/, '')}/acp`;
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.searchParams.set('token', token);
return url.toString();
};
let appConfig = {
GOOSE_DEFAULT_PROVIDER: defaultProvider,
GOOSE_DEFAULT_MODEL: defaultModel,
@@ -1624,6 +1632,19 @@ ipcMain.handle('get-goosed-host-port', async (event) => {
return client.getConfig().baseUrl || null;
});
ipcMain.handle('get-acp-url', async (event) => {
const windowId = BrowserWindow.fromWebContents(event.sender)?.id;
if (!windowId) {
return null;
}
const client = goosedClients.get(windowId);
const baseUrl = client?.getConfig().baseUrl;
if (!baseUrl) {
return null;
}
return buildAcpWebSocketUrl(baseUrl, getServerSecret(getSettings()));
});
// Handle menu bar icon visibility
ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => {
updateSettings((s) => {
+2
View File
@@ -142,6 +142,7 @@ type ElectronAPI = {
setSetting: <K extends SettingKey>(key: K, value: Settings[K]) => Promise<void>;
getSecretKey: () => Promise<string>;
getGoosedHostPort: () => Promise<string | null>;
getAcpUrl: () => Promise<string | null>;
setWakelock: (enable: boolean) => Promise<boolean>;
getWakelockState: () => Promise<boolean>;
setSpellcheck: (enable: boolean) => Promise<boolean>;
@@ -265,6 +266,7 @@ const electronAPI: ElectronAPI = {
},
getSecretKey: () => ipcRenderer.invoke('get-secret-key'),
getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'),
getAcpUrl: () => ipcRenderer.invoke('get-acp-url'),
setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable),
getWakelockState: () => ipcRenderer.invoke('get-wakelock-state'),
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
@@ -7,6 +7,7 @@ describe('buildConnectSrc', () => {
const result = buildConnectSrc(undefined);
expect(result).toContain("'self'");
expect(result).toContain('http://127.0.0.1:*');
expect(result).toContain('wss://127.0.0.1:*');
});
it('includes external backend origin when enabled', () => {
@@ -17,6 +18,18 @@ describe('buildConnectSrc', () => {
};
const result = buildConnectSrc(config);
expect(result).toContain('http://dev.company.net:12604');
expect(result).toContain('ws://dev.company.net:12604');
});
it('includes external secure WebSocket origin for HTTPS backends', () => {
const config: ExternalGoosedConfig = {
enabled: true,
url: 'https://secure.company.net:12604',
secret: 'test',
};
const result = buildConnectSrc(config);
expect(result).toContain('https://secure.company.net:12604');
expect(result).toContain('wss://secure.company.net:12604');
});
it('does not include external origin when disabled', () => {
+6
View File
@@ -4,8 +4,12 @@ const DEFAULT_CONNECT_SOURCES = [
"'self'",
'http://127.0.0.1:*',
'https://127.0.0.1:*',
'ws://127.0.0.1:*',
'wss://127.0.0.1:*',
'http://localhost:*',
'https://localhost:*',
'ws://localhost:*',
'wss://localhost:*',
'https://api.github.com',
'https://github.com',
'https://objects.githubusercontent.com',
@@ -18,6 +22,8 @@ export function buildConnectSrc(externalGoosed?: ExternalGoosedConfig): string {
try {
const externalUrl = new URL(externalGoosed.url);
sources.push(externalUrl.origin);
externalUrl.protocol = externalUrl.protocol === 'https:' ? 'wss:' : 'ws:';
sources.push(externalUrl.origin);
} catch {
console.warn('Invalid external goosed URL in settings, skipping CSP entry');
}
+3
View File
@@ -21,6 +21,9 @@ importers:
'@aaif/goose-sdk':
specifier: workspace:*
version: link:../sdk
'@agentclientprotocol/sdk':
specifier: ^0.19.0
version: 0.19.0(zod@3.25.76)
'@mcp-ui/client':
specifier: 6.1.0
version: 6.1.0(@preact/signals-core@1.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)