Make a plan

This commit is contained in:
Jack Amadeo
2026-04-14 12:43:48 -04:00
parent 8e04d7c8be
commit b5ce2502d6
11 changed files with 3007 additions and 0 deletions
@@ -0,0 +1,104 @@
# ACP-Plus Migration Plan: Overview
## Goal
Move all ACP protocol handling from the Rust Tauri backend into the TypeScript/WebView layer, so the frontend communicates directly with `goose serve` over HTTP+SSE. The Rust layer shrinks to a thin native shell responsible only for:
1. Spawning and managing the `goose serve` child process
2. Providing the server URL to the frontend
3. Window management / OS integration
Long-term, config, personas, skills, projects, git, doctor, and all other native operations will also move behind `goose serve` ACP extension methods — eliminating the Rust middleware entirely.
## Current Architecture
```
Frontend (TS)
→ invoke("acp_send_message") [Tauri IPC]
→ GooseAcpManager [Rust singleton, dedicated thread]
→ ClientSideConnection [Rust ACP client over WebSocket]
→ goose serve ws://127.0.0.1:<port>/acp [child process]
← SessionNotification [ACP callback in Rust]
← TauriMessageWriter [emits Tauri events]
← listen("acp:text", ...) [Tauri event bus]
→ Zustand store updates
```
## Target Architecture (Phase A)
```
Frontend (TS)
→ GooseClient (HTTP+SSE)
→ goose serve http://127.0.0.1:<port>/acp [child process]
← Client callbacks → direct Zustand store updates
Tauri Rust shell:
- Spawn goose serve, expose URL
- Config/personas/skills/projects/git/doctor (temporary — Phase B removes these)
- Window management
```
## Target Architecture (Phase B — Long-Term)
```
Frontend (TS)
→ GooseClient (HTTP+SSE)
→ goose serve http://127.0.0.1:<port>/acp
← Client callbacks → direct Zustand store updates
Tauri Rust shell (~200 lines):
- Spawn goose serve, expose URL
- Window management
- (nothing else)
```
## Steps
| Step | File | Summary |
|------|------|---------|
| 01 | `01-expose-goose-serve-url.md` | Add Tauri command to expose the `goose serve` HTTP URL to the frontend |
| 02 | `02-add-acp-npm-dependencies.md` | Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` to goose2 |
| 03 | `03-create-ts-acp-connection.md` | Create the singleton TypeScript ACP connection manager |
| 04 | `04-create-ts-notification-handler.md` | Port the Rust `SessionEventDispatcher` to TypeScript |
| 05 | `05-create-ts-session-manager.md` | Port session state management and ACP operations to TypeScript |
| 06 | `06-port-session-search.md` | Port session content search from Rust to TypeScript |
| 07 | `07-rewire-shared-api-acp.md` | Replace `invoke()` wrappers in `src/shared/api/acp.ts` with direct TS ACP calls |
| 08 | `08-rewire-hooks.md` | Remove `useAcpStream`, update `useChat`, `useAppStartup`, `AppShell` |
| 09 | `09-delete-rust-acp-code.md` | Delete the Rust ACP middleware and unused dependencies |
| 10 | `10-phase-b-future-native-migration.md` | Plan for moving config/personas/skills/projects/git/doctor to `goose serve` |
## Ordering & Dependencies
```
01 ──┐
├──→ 03 ──→ 04 ──→ 05 ──→ 07 ──→ 08 ──→ 09
02 ──┘ │
└──→ 06 ──→ 07
```
Steps 01 and 02 are independent and can be done in parallel.
Steps 03–06 build on each other but 06 can be done in parallel with 04/05.
Step 07 wires everything together.
Step 08 removes the old Tauri event listeners.
Step 09 is cleanup — only after everything works.
Step 10 is the Phase B roadmap.
## Key Decisions
1. **HTTP+SSE over WebSocket**: `goose serve` supports both. HTTP+SSE is already battle-tested by `ui/acp`'s `createHttpStream`. Visible in browser DevTools. Can switch to WS later if needed.
2. **Direct store updates over event bus**: The notification handler calls Zustand store methods directly instead of emitting Tauri events. Eliminates a layer of indirection and the `useAcpStream` hook.
3. **Reuse `@aaif/goose-acp`**: Already used by `ui/desktop` (Electron) and `ui/text` (Ink TUI). Provides `GooseClient`, `createHttpStream`, generated types, and Zod validators.
4. **Auto-approve permissions**: Same as the current Rust implementation — accept the first option on all `request_permission` callbacks.
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Tauri CSP blocks localhost fetch | CSP is already `null` (disabled) in `tauri.conf.json` |
| `goose serve` not ready when frontend initializes | Rust still does readiness check; URL command only resolves after server is confirmed ready |
| HTTP+SSE performance vs WebSocket | Both transports are supported; can switch later. SSE has keep-alive. |
| Replay timing (notifications arriving after `loadSession` resolves) | Port the drain/stabilization logic from Rust, or rely on the `replay_complete` signal from the backend |
| Session state consistency during migration | Can keep old Rust path behind a flag initially; remove after validation |
@@ -0,0 +1,117 @@
# Step 01: Expose the `goose serve` URL to the Frontend
## Objective
Add a single new Tauri command that returns the HTTP base URL of the running `goose serve` process. The frontend will use this URL to connect directly via HTTP+SSE.
## Why
Currently the Rust layer connects to `goose serve` over WebSocket internally and proxies everything. The frontend never knows the server URL. We need to expose it so the TypeScript ACP client can connect directly.
## Changes
### 1. Make `port` accessible on `GooseServeProcess`
**File:** `src-tauri/src/services/acp/goose_serve.rs`
Add a public getter for the port:
```rust
impl GooseServeProcess {
/// Return the HTTP base URL for this server (used by the frontend).
pub fn http_url(&self) -> String {
format!("http://{LOCALHOST}:{}", self.port)
}
// ... existing methods unchanged ...
}
```
The `port` field is already stored on the struct but is only used internally via `ws_url()`. The new `http_url()` method provides the HTTP variant.
### 2. Add the Tauri command
**File:** `src-tauri/src/commands/acp.rs`
Add this command alongside the existing ones (it will coexist during migration):
```rust
/// Return the HTTP base URL of the running goose serve process.
///
/// This command blocks until the server is confirmed ready. The frontend
/// uses this URL to establish a direct HTTP+SSE ACP connection.
#[tauri::command]
pub async fn get_goose_serve_url() -> Result<String, String> {
// GooseServeProcess::start() is idempotent — it returns immediately
// if the process is already running.
GooseServeProcess::start().await?;
let process = GooseServeProcess::get()?;
Ok(process.http_url())
}
```
Add the necessary import at the top of the file if not already present:
```rust
use crate::services::acp::goose_serve::GooseServeProcess;
```
Note: `GooseServeProcess` is currently re-exported from `services::acp` as `resolve_goose_binary` but the struct itself is used via `goose_serve::GooseServeProcess` internally. You may need to add a `pub use` in `services/acp/mod.rs`:
```rust
pub(crate) use goose_serve::GooseServeProcess;
```
### 3. Register the command
**File:** `src-tauri/src/lib.rs`
Add the new command to the `invoke_handler` macro:
```rust
commands::acp::get_goose_serve_url,
```
Place it near the other `commands::acp::*` entries.
### 4. Verify CSP allows localhost fetch
**File:** `src-tauri/tauri.conf.json`
Check the `security.csp` field. Currently it is:
```json
"security": {
"csp": null,
...
}
```
`null` means CSP is disabled — **no changes needed**. The frontend can freely fetch from `http://127.0.0.1:*`.
If CSP were ever re-enabled, you'd need to add:
```
connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*
```
## Verification
1. Run `just tauri-check` (or `cargo check` in `src-tauri/`) to confirm the Rust compiles.
2. Run `cargo clippy --all-targets -- -D warnings` in `src-tauri/`.
3. Run `cargo fmt` in `src-tauri/`.
4. Manually test by adding a temporary `console.log(await invoke("get_goose_serve_url"))` in the frontend startup — it should print something like `http://127.0.0.1:54321`.
## Files Modified
| File | Change |
|------|--------|
| `src-tauri/src/services/acp/goose_serve.rs` | Add `http_url()` method |
| `src-tauri/src/services/acp/mod.rs` | Add `pub(crate) use goose_serve::GooseServeProcess` if needed |
| `src-tauri/src/commands/acp.rs` | Add `get_goose_serve_url` command |
| `src-tauri/src/lib.rs` | Register `get_goose_serve_url` in invoke_handler |
## Notes
- The existing ACP commands remain functional during migration. They will be removed in Step 09.
- `GooseServeProcess::start()` is idempotent — calling it multiple times is safe. The first call spawns the process; subsequent calls return immediately.
- The readiness check (WebSocket probe loop) ensures the URL is only returned after the server is accepting connections.
@@ -0,0 +1,150 @@
# Step 02: Add ACP NPM Dependencies to goose2
## Objective
Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` as dependencies of the goose2 frontend so we can use the TypeScript ACP client.
## Why
The `@aaif/goose-acp` package (located at `ui/acp/` in the monorepo) already provides:
- **`GooseClient`** — a full TypeScript ACP client wrapping `ClientSideConnection`
- **`createHttpStream`** — an HTTP+SSE transport that speaks the same protocol as `goose serve`
- **`GooseExtClient`** — generated typed client for Goose extension methods (`goose/providers/list`, `goose/session/export`, etc.)
- **Generated types + Zod validators** for all Goose ACP extension method request/response shapes
This package is already used by `ui/desktop` (Electron) and `ui/text` (Ink TUI). goose2 currently does NOT depend on it.
## Changes
### 1. Add dependencies
**File:** `ui/goose2/package.json`
Run from the `ui/goose2/` directory:
```bash
source ./bin/activate-hermit
pnpm add @aaif/goose-acp @agentclientprotocol/sdk
```
If the packages are not published to npm yet or you want to use the local workspace version, use the workspace protocol instead. Check `ui/pnpm-workspace.yaml` to see if `ui/acp` is included in the workspace. If it is:
```bash
pnpm add @aaif/goose-acp@workspace:* @agentclientprotocol/sdk
```
If `ui/acp` is NOT in the pnpm workspace (goose2 has its own `pnpm-lock.yaml`), you have two options:
**Option A — Link locally during development:**
```bash
cd ui/acp
npm run build
npm link
cd ui/goose2
pnpm link @aaif/goose-acp
pnpm add @agentclientprotocol/sdk
```
**Option B — Use the published npm package:**
```bash
cd ui/goose2
pnpm add @aaif/goose-acp @agentclientprotocol/sdk
```
### 2. Verify the dependency resolves
After installation, verify the imports work:
```bash
cd ui/goose2
# Quick typecheck
pnpm typecheck
```
Create a temporary test file to confirm imports resolve:
```typescript
// src/shared/api/_test_acp_import.ts (DELETE AFTER VERIFICATION)
import { GooseClient, createHttpStream } from "@aaif/goose-acp";
import type { Client, SessionNotification } from "@agentclientprotocol/sdk";
console.log("GooseClient:", GooseClient);
console.log("createHttpStream:", createHttpStream);
```
Run `pnpm typecheck` to confirm no type errors. Then delete the test file.
### 3. Verify key exports are available
The following imports must resolve — these are what Steps 03–06 will use:
From `@aaif/goose-acp`:
```typescript
import { GooseClient, createHttpStream } from "@aaif/goose-acp";
```
From `@agentclientprotocol/sdk`:
```typescript
import type {
Client,
SessionNotification,
SessionUpdate,
ContentBlock,
ToolCallContent,
RequestPermissionRequest,
RequestPermissionResponse,
NewSessionRequest,
NewSessionResponse,
LoadSessionRequest,
LoadSessionResponse,
PromptRequest,
PromptResponse,
CancelNotification,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
ForkSessionRequest,
ForkSessionResponse,
ListSessionsRequest,
ListSessionsResponse,
InitializeRequest,
ProtocolVersion,
Implementation,
SessionModelState,
SessionInfoUpdate,
SessionConfigOption,
SessionConfigKind,
SessionConfigSelectOptions,
SessionConfigOptionCategory,
} from "@agentclientprotocol/sdk";
```
### 4. Check `@agentclientprotocol/sdk` version compatibility
The `@aaif/goose-acp` package declares `@agentclientprotocol/sdk` as a **peer dependency** (`"*"`). The Rust backend currently uses `agent-client-protocol = "0.10.4"`. The TypeScript SDK should be at a compatible version.
Check `ui/acp/package.json` for the devDependency version — it currently shows `"@agentclientprotocol/sdk": "^0.14.1"`. Install the same or newer version:
```bash
pnpm add @agentclientprotocol/sdk@^0.14.1
```
## Verification
1. `pnpm typecheck` passes with no errors related to the new dependencies.
2. `pnpm check` (Biome lint + file sizes) passes.
3. `pnpm test` still passes (no existing tests should break).
## Files Modified
| File | Change |
|------|--------|
| `package.json` | Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` to dependencies |
| `pnpm-lock.yaml` | Auto-updated by pnpm |
## Notes
- The `@aaif/goose-acp` package exports `GooseClient` which wraps `ClientSideConnection` from `@agentclientprotocol/sdk`. It adds Goose-specific extension methods via `GooseExtClient`.
- The `createHttpStream` function creates a `Stream` (a `{ readable, writable }` pair of `ReadableStream<AnyMessage>` and `WritableStream<AnyMessage>`) that communicates with `goose serve` over HTTP POST + SSE.
- The HTTP+SSE transport works by: (1) POSTing JSON-RPC messages to `/acp`, (2) receiving responses and notifications via Server-Sent Events on the same connection. The first POST (initialize) establishes the SSE stream; subsequent POSTs use the `Acp-Session-Id` header.
@@ -0,0 +1,213 @@
# Step 03: Create the TypeScript ACP Connection Manager
## Objective
Create a singleton module that manages the lifecycle of the `GooseClient` connection to `goose serve`. This is the TypeScript equivalent of the Rust `GooseAcpManager::start()` singleton.
## Why
All ACP operations (send prompt, list sessions, export, etc.) need a shared, initialized `GooseClient` instance. This module:
1. Fetches the `goose serve` URL from the Rust backend (Step 01's command)
2. Creates a `GooseClient` with `createHttpStream`
3. Calls `client.initialize()` to complete the ACP handshake
4. Provides the initialized client to all other modules
## New File
### `src/shared/api/acpConnection.ts`
```typescript
/**
* Singleton ACP connection manager.
*
* Manages the lifecycle of the GooseClient connection to goose serve.
* All ACP operations go through the client returned by getClient().
*/
import { invoke } from "@tauri-apps/api/core";
import { GooseClient, createHttpStream } from "@aaif/goose-acp";
import type { Client, SessionNotification, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk";
// Will be set by Step 04 — the notification handler
let notificationHandler: AcpNotificationHandler | null = null;
/**
* Interface for the notification handler that processes ACP session events.
* Implemented in Step 04 (acpNotificationHandler.ts).
*/
export interface AcpNotificationHandler {
handleSessionNotification(notification: SessionNotification): Promise<void>;
}
/**
* Register the notification handler. Called once during app initialization
* after the handler is created in Step 04.
*/
export function setNotificationHandler(handler: AcpNotificationHandler): void {
notificationHandler = handler;
}
// Singleton state
let clientPromise: Promise<GooseClient> | null = null;
let resolvedClient: GooseClient | null = null;
/**
* Build the Client implementation that the ACP SDK calls back into.
*
* This handles two callback types:
* - request_permission: auto-approve with the first option (same as Rust impl)
* - session_notification: delegate to the registered notification handler
*/
function createClientCallbacks(): () => Client {
return () => ({
requestPermission: async (
args: RequestPermissionRequest,
): Promise<RequestPermissionResponse> => {
// Auto-approve with the first available option, matching the Rust behavior.
const optionId = args.options?.[0]?.optionId ?? "approve";
return {
outcome: {
type: "selected",
optionId,
},
};
},
sessionNotification: async (
notification: SessionNotification,
): Promise<void> => {
if (notificationHandler) {
await notificationHandler.handleSessionNotification(notification);
}
},
});
}
/**
* Initialize the ACP connection.
*
* 1. Calls the Rust backend to get the goose serve URL
* 2. Creates a GooseClient with HTTP+SSE transport
* 3. Sends the ACP initialize handshake
*
* This is idempotent — calling it multiple times returns the same client.
*/
async function initializeConnection(): Promise<GooseClient> {
// Get the goose serve URL from the Rust backend.
// This blocks until the server is confirmed ready.
const serverUrl: string = await invoke("get_goose_serve_url");
const client = new GooseClient(
createClientCallbacks(),
createHttpStream(serverUrl),
);
// Perform the ACP initialize handshake
await client.initialize({
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: {
name: "goose2",
version: "0.1.0",
},
});
return client;
}
/**
* Get the initialized GooseClient singleton.
*
* The first call triggers initialization (fetching the URL, creating the
* connection, running the ACP handshake). Subsequent calls return the
* same client immediately.
*
* Throws if initialization fails (e.g., goose serve is not running).
*/
export async function getClient(): Promise<GooseClient> {
if (resolvedClient) {
return resolvedClient;
}
if (!clientPromise) {
clientPromise = initializeConnection().then((client) => {
resolvedClient = client;
return client;
}).catch((error) => {
// Reset so the next call retries
clientPromise = null;
throw error;
});
}
return clientPromise;
}
/**
* Check if the client has been initialized.
* Useful for guards that need to know if ACP is ready without triggering init.
*/
export function isClientReady(): boolean {
return resolvedClient !== null;
}
/**
* Get the client synchronously, or null if not yet initialized.
* Use getClient() for the async version that triggers initialization.
*/
export function getClientSync(): GooseClient | null {
return resolvedClient;
}
```
## Architecture Notes
### Singleton Pattern
The module uses a promise-based singleton pattern:
- `clientPromise` ensures only one initialization runs at a time
- `resolvedClient` caches the result for synchronous access
- If initialization fails, `clientPromise` is reset so the next call retries
This mirrors the Rust `OnceCell<Arc<GooseAcpManager>>` pattern in `manager.rs`.
### Notification Handler Registration
The notification handler is registered separately (in Step 04) rather than being passed to `createClientCallbacks` at construction time. This avoids a circular dependency:
- `acpConnection.ts` creates the client
- `acpNotificationHandler.ts` needs the client (to know about sessions)
- `acpNotificationHandler.ts` needs to be registered with the connection
The `setNotificationHandler()` function breaks this cycle.
### Protocol Version
The `protocolVersion` in the initialize request should match what `goose serve` expects. The Rust code uses `ProtocolVersion::LATEST` from the `agent-client-protocol` crate. Check the `@agentclientprotocol/sdk` package for the equivalent constant — it may be exported as `LATEST_PROTOCOL_VERSION` or similar. If not, use the string `"2025-03-26"` (or whatever the current latest is).
### Error Handling
If `invoke("get_goose_serve_url")` fails (e.g., goose binary not found), the error propagates to the caller. The app startup code (Step 08) should handle this gracefully — showing an error state rather than crashing.
## Verification
1. `pnpm typecheck` passes.
2. `pnpm check` passes (Biome lint).
3. The module can be imported without side effects — initialization only happens when `getClient()` is called.
## Files Created
| File | Purpose |
|------|---------|
| `src/shared/api/acpConnection.ts` | Singleton ACP connection manager |
## Dependencies
- Step 01 (the `get_goose_serve_url` Tauri command must exist)
- Step 02 (`@aaif/goose-acp` and `@agentclientprotocol/sdk` must be installed)
## Notes
- The `createHttpStream` transport from `@aaif/goose-acp` handles the HTTP+SSE protocol details: POSTing JSON-RPC to `/acp`, reading SSE responses, managing the `Acp-Session-Id` header.
- The `GooseClient` constructor takes `() => Client` (a factory function that returns the callback object) and a `Stream` (the transport). It wraps `ClientSideConnection` from the SDK.
- The `requestPermission` callback shape may differ slightly between SDK versions. Check the `@agentclientprotocol/sdk` types for the exact interface. The Rust code returns `RequestPermissionResponse::new(RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)))` — the TS equivalent should match.
- The `initialize` request params shape may also vary. Check `InitializeRequest` in the SDK. The Rust code sends `InitializeRequest::new(ProtocolVersion::LATEST).client_info(Implementation::new("goose2", env!("CARGO_PKG_VERSION")))`.
@@ -0,0 +1,340 @@
# Step 04: Create the TypeScript Notification Handler
## Objective
Port the Rust `SessionEventDispatcher` (in `src-tauri/src/services/acp/manager/dispatcher.rs`) to TypeScript. This module receives ACP `SessionNotification` events and updates Zustand stores directly — replacing the current Tauri event bus (`acp:text`, `acp:tool_call`, etc.) and the `useAcpStream` hook.
## Why
Currently, ACP notifications flow through three layers:
1. Rust `SessionEventDispatcher` receives the ACP callback
2. Rust emits Tauri events (`acp:text`, `acp:done`, etc.)
3. TypeScript `useAcpStream` hook listens to those events and updates stores
By handling notifications directly in TypeScript, we eliminate the Tauri event bus intermediary and the `useAcpStream` hook entirely.
## New File
### `src/shared/api/acpNotificationHandler.ts`
This file implements the `AcpNotificationHandler` interface from Step 03 and contains all the logic currently split between `dispatcher.rs`, `writer.rs`, and `useAcpStream.ts`.
```typescript
/**
* ACP notification handler — processes SessionNotification events from the
* GooseClient and updates Zustand stores directly.
*
* This replaces:
* - Rust: dispatcher.rs (SessionEventDispatcher, Client trait impl)
* - Rust: writer.rs (TauriMessageWriter)
* - TS: useAcpStream.ts (Tauri event listeners)
* - TS: replayBuffer.ts (replay buffering)
*/
```
## Key Data Structures to Port
### Session Route Map
The Rust `dispatcher.rs` maintains a `HashMap<String, SessionRoute>` that maps goose session IDs to local session IDs. Port this as:
```typescript
interface SessionRoute {
localSessionId: string;
providerId: string | null;
/** When non-null, this session is actively streaming (live path). */
activeMessageId: string | null;
/** When true, the session was cancelled mid-stream. */
canceled: boolean;
/** Persona info for the active stream. */
personaId: string | null;
personaName: string | null;
}
// Module-level state
const routes = new Map<string, SessionRoute>();
```
### Replay Buffer
Port the replay buffer from `replayBuffer.ts` into this module (or keep it as a separate import). The key insight: during `loadSession`, notifications arrive for historical messages. These are buffered and flushed as a single `store.setMessages()` call when `replay_complete` is signaled.
```typescript
import type { Message, MessageContent, ToolRequestContent } from "@/shared/types/messages";
const replayBuffers = new Map<string, Message[]>();
```
## Notification Dispatch Logic
Port the `session_notification` method from `dispatcher.rs`. The Rust code handles these `SessionUpdate` variants:
### 1. `SessionInfoUpdate`
```typescript
// Rust: self.emit_session_info(...)
// TS equivalent:
function handleSessionInfoUpdate(localSessionId: string, info: SessionInfoUpdate): void {
const session = useChatSessionStore.getState().getSession(localSessionId);
if (info.title && !session?.userSetName) {
useChatSessionStore.getState().updateSession(localSessionId, {
title: info.title,
}, { persistOverlay: false });
}
}
```
### 2. `ConfigOptionUpdate` (model state)
Port the `emit_model_state_from_options` logic. The Rust code extracts model options from `SessionConfigSelectOptions` (ungrouped or grouped) and emits them. In TS:
```typescript
import type { ModelOption } from "@/features/chat/types";
function extractModelOptionsFromConfigOptions(
options: SessionConfigOption[],
): { currentModelId: string; currentModelName: string | null; availableModels: ModelOption[] } | null {
const modelOption = options.find(
(opt) => opt.category === "model" // SessionConfigOptionCategory::Model
);
if (!modelOption || modelOption.kind.type !== "select") return null;
const select = modelOption.kind;
const currentModelId = select.currentValue;
const availableModels: ModelOption[] = [];
if (select.options.type === "ungrouped") {
for (const value of select.options.values) {
availableModels.push({ id: value.value, name: value.name });
}
} else if (select.options.type === "grouped") {
for (const group of select.options.groups) {
for (const value of group.options) {
availableModels.push({ id: value.value, name: value.name });
}
}
}
const currentModelName = availableModels.find(m => m.id === currentModelId)?.name ?? null;
return { currentModelId, currentModelName, availableModels };
}
function handleModelState(
localSessionId: string,
providerId: string | null,
modelState: { currentModelId: string; currentModelName: string | null; availableModels: ModelOption[] },
): void {
const sessionStore = useChatSessionStore.getState();
if (providerId) {
sessionStore.cacheModelsForProvider(providerId, modelState.availableModels);
}
const session = sessionStore.getSession(localSessionId);
const sessionProvider = session?.providerId;
if (providerId && sessionProvider && providerId !== sessionProvider) {
return; // provider mismatch — ignore
}
const modelName = modelState.currentModelName ?? modelState.currentModelId;
sessionStore.setSessionModels(localSessionId, modelState.availableModels);
if (!providerId && session?.modelId) {
return;
}
sessionStore.updateSession(localSessionId, {
modelId: modelState.currentModelId,
modelName,
}, { persistOverlay: false });
}
```
### 3. `AgentMessageChunk` (live streaming — text)
When a route has an `activeMessageId` (live streaming path):
```typescript
function handleLiveText(localSessionId: string, text: string): void {
const store = useChatStore.getState();
store.updateStreamingText(localSessionId, text);
}
```
When in replay mode (no `activeMessageId`, session is loading):
```typescript
function handleReplayText(localSessionId: string, gooseSessionId: string, text: string): void {
const buffer = replayBuffers.get(localSessionId);
if (!buffer) return;
const route = routes.get(gooseSessionId);
// Find or create the current assistant message in the buffer
// (same logic as useAcpStream's acp:text handler for loading sessions)
// ...
}
```
### 4. `ToolCall` and `ToolCallUpdate`
Port the tool call handling from both the live and replay paths. The live path calls:
```typescript
store.appendToStreamingMessage(sessionId, toolRequest);
```
The replay path appends to the buffer message.
### 5. `UserMessageChunk` (replay only)
During replay, user messages arrive as `UserMessageChunk`. Port the `extract_user_message` helper:
```typescript
function extractUserMessage(raw: string): string {
const openTag = "<user-message>\n";
const closeTag = "\n</user-message>";
const startIdx = raw.indexOf(openTag);
if (startIdx >= 0) {
const innerStart = startIdx + openTag.length;
if (raw.substring(innerStart).endsWith(closeTag)) {
return raw.substring(innerStart, raw.length - closeTag.length);
}
}
return raw;
}
```
### 6. Done / Finalize
When a streaming message completes (the `prompt()` call resolves), the session manager (Step 05) calls a finalize method:
```typescript
export function finalizeMessage(localSessionId: string, messageId: string): void {
const store = useChatStore.getState();
store.updateMessage(localSessionId, messageId, (message) => {
const content = message.content.map((block) =>
block.type === "toolRequest" && block.status === "executing"
? { ...block, status: "completed" as const }
: block,
);
return {
...message,
content,
metadata: { ...message.metadata, completionStatus: "completed" },
};
});
store.setStreamingMessageId(localSessionId, null);
store.setChatState(localSessionId, "idle");
// ... title update logic from useAcpStream's acp:done handler
}
```
## Public API
The module should export:
```typescript
/** Register a goose session ID → local session ID binding. */
export function bindSession(gooseSessionId: string, localSessionId: string, providerId?: string): void;
/** Attach a "writer" for live streaming — sets the active message ID. */
export function attachWriter(gooseSessionId: string, localSessionId: string, providerId: string | null, messageId: string, personaId?: string, personaName?: string): void;
/** Clear the active writer after streaming completes. */
export function clearWriter(gooseSessionId: string): void;
/** Mark a session as cancelled. */
export function markCanceled(gooseSessionId: string): boolean;
/** Start replay buffering for a session. */
export function startReplayBuffer(localSessionId: string): void;
/** Finalize replay — flush buffer to store. */
export function finalizeReplay(gooseSessionId: string): void;
/** Flush the replay buffer for a session (called when loading completes). */
export function flushReplayBuffer(localSessionId: string): void;
/** Finalize a completed streaming message. */
export function finalizeMessage(localSessionId: string, messageId: string): void;
/** The main notification handler — implements AcpNotificationHandler from Step 03. */
export function handleSessionNotification(notification: SessionNotification): Promise<void>;
```
## Porting Checklist
Port each piece from the Rust source, mapping to the TS equivalent:
| Rust Source | Rust Function/Method | TS Equivalent |
|-------------|---------------------|---------------|
| `dispatcher.rs` | `SessionEventDispatcher::session_notification` | `handleSessionNotification()` |
| `dispatcher.rs` | `SessionEventDispatcher::bind_session` | `bindSession()` |
| `dispatcher.rs` | `SessionEventDispatcher::attach_writer` | `attachWriter()` |
| `dispatcher.rs` | `SessionEventDispatcher::clear_writer` | `clearWriter()` |
| `dispatcher.rs` | `SessionEventDispatcher::mark_canceled` | `markCanceled()` |
| `dispatcher.rs` | `SessionEventDispatcher::finalize_replay` | `finalizeReplay()` |
| `dispatcher.rs` | `SessionEventDispatcher::emit_session_info` | `handleSessionInfoUpdate()` |
| `dispatcher.rs` | `SessionEventDispatcher::emit_model_state` | `handleModelState()` |
| `dispatcher.rs` | `SessionEventDispatcher::emit_model_state_from_options` | `handleModelState()` via `extractModelOptionsFromConfigOptions()` |
| `dispatcher.rs` | `SessionEventDispatcher::emit_replay_complete` | `flushReplayBuffer()` + `store.setSessionLoading(false)` |
| `dispatcher.rs` | `extract_user_message` | `extractUserMessage()` |
| `dispatcher.rs` | `extract_content_preview` | `extractContentPreview()` |
| `writer.rs` | `TauriMessageWriter::append_text` | Handled inline in `handleSessionNotification` |
| `writer.rs` | `TauriMessageWriter::record_tool_call` | Handled inline in `handleSessionNotification` |
| `writer.rs` | `TauriMessageWriter::record_tool_result` | Handled inline in `handleSessionNotification` |
| `writer.rs` | `TauriMessageWriter::finalize` | `finalizeMessage()` |
| `useAcpStream.ts` | All event listeners | Replaced by `handleSessionNotification()` |
| `replayBuffer.ts` | Buffer management | Inlined or imported |
## Store Methods Used
The notification handler calls these existing Zustand store methods (no changes needed to the stores):
**`useChatStore`:**
- `addMessage(sessionId, message)`
- `updateMessage(sessionId, messageId, updater)`
- `setMessages(sessionId, messages)` — for replay buffer flush
- `updateStreamingText(sessionId, text)`
- `appendToStreamingMessage(sessionId, content)`
- `setStreamingMessageId(sessionId, id)`
- `setChatState(sessionId, state)`
- `setPendingAssistantProvider(sessionId, null)`
- `setSessionLoading(sessionId, loading)`
- `markSessionUnread(sessionId)`
- `setError(sessionId, error)`
**`useChatSessionStore`:**
- `updateSession(sessionId, patch, opts)`
- `setSessionAcpId(sessionId, acpSessionId)`
- `setSessionModels(sessionId, models)`
- `cacheModelsForProvider(providerId, models)`
- `getSession(sessionId)`
## Registration
During app initialization (Step 08), after creating the notification handler, register it with the connection manager:
```typescript
import { setNotificationHandler } from "@/shared/api/acpConnection";
import * as notificationHandler from "@/shared/api/acpNotificationHandler";
setNotificationHandler(notificationHandler);
```
## Verification
1. `pnpm typecheck` passes.
2. `pnpm check` passes.
3. Unit tests for `extractUserMessage` and `extractContentPreview` (port the Rust tests from `dispatcher_tests.rs`).
## Files Created
| File | Purpose |
|------|---------|
| `src/shared/api/acpNotificationHandler.ts` | ACP notification handler — replaces dispatcher.rs + writer.rs + useAcpStream.ts |
## Dependencies
- Step 03 (`acpConnection.ts` must exist for the `AcpNotificationHandler` interface)
- Zustand stores (`useChatStore`, `useChatSessionStore`) — no changes needed
## Notes
- The Rust dispatcher uses `Arc<Mutex<HashMap>>` for thread safety. In single-threaded JS, a plain `Map` suffices.
- The Rust dispatcher has a `replay_events` counter and `wait_for_replay_drain` for timing. In TS, we rely on the `replay_complete` signal from the backend (which the Rust code also emits after draining). The `loadSession` RPC resolves, then the backend sends remaining notifications, then sends `replay_complete`. We just need to handle the `replay_complete` notification to flush the buffer.
- The `SessionNotification` type from `@agentclientprotocol/sdk` has a `sessionId` field (the goose session ID) and an `update` field with the variant. Check the SDK types for the exact shape — it may differ slightly from the Rust `agent_client_protocol::SessionNotification`.
- The `shouldTrackStreamingEvent` guard from `useAcpStream.ts` should be ported — it prevents stale events from updating already-completed messages.
@@ -0,0 +1,495 @@
# Step 05: Create the TypeScript Session Manager
## Objective
Port the session state management and ACP operations from the Rust `session_ops.rs`, `command_dispatch.rs`, and `registry.rs` to TypeScript. This module orchestrates all ACP calls (prepare session, send prompt, cancel, load, list, export, import, fork, set model, list providers).
## Why
The Rust `GooseAcpManager` + `session_ops` is the core orchestration layer that:
1. Tracks which goose sessions are prepared (composite key → goose session ID)
2. Creates or loads goose sessions on demand
3. Sets provider/model/working-dir on sessions
4. Sends prompts and coordinates with the notification handler for streaming
5. Handles cancellation
6. Provides session CRUD (list, export, import, fork)
All of this is pure protocol logic with no native OS access — it belongs in TypeScript.
## New File
### `src/shared/api/acpSessionManager.ts`
## Key Data Structures
### Prepared Session Cache
Port from `session_ops.rs`:
```typescript
interface PreparedSession {
gooseSessionId: string;
providerId: string;
workingDir: string;
}
/** Maps composite key (sessionId or sessionId__personaId) → PreparedSession */
const preparedSessions = new Map<string, PreparedSession>();
```
### Composite Key Helpers
Port from `mod.rs`:
```typescript
export function makeCompositeKey(sessionId: string, personaId?: string): string {
if (personaId && personaId.length > 0) {
return `${sessionId}__${personaId}`;
}
return sessionId;
}
export function splitCompositeKey(key: string): { sessionId: string; personaId: string | null } {
const idx = key.indexOf("__");
if (idx >= 0) {
const personaId = key.substring(idx + 2);
if (personaId.length > 0) {
return { sessionId: key.substring(0, idx), personaId };
}
}
return { sessionId: key, personaId: null };
}
```
### Running Session Tracking
Port from `registry.rs`:
```typescript
interface RunningSession {
compositeKey: string;
providerId: string;
startedAt: number; // Date.now()
assistantMessageId: string | null;
abortController: AbortController;
}
const runningSessions = new Map<string, RunningSession>();
```
## Core Operations to Port
### 1. `prepareSession`
Port from `prepare_session_inner` in `session_ops.rs`. This is the most complex function — it:
1. Checks if a session is already prepared for this composite key
2. If yes, reuses it (updating working dir / provider if changed)
3. If no, tries to load an existing goose session by ID
4. If that fails, creates a new goose session via `client.newSession()`
5. Binds the goose session ID to the local session ID in the notification handler
6. Sets the provider via `client.setSessionConfigOption()` if needed
7. Emits model state to the session store
```typescript
export async function prepareSession(
compositeKey: string,
localSessionId: string,
providerId: string,
workingDir: string,
): Promise<string> {
const client = await getClient();
// Check for existing prepared session
const existing = preparedSessions.get(compositeKey) ?? preparedSessions.get(localSessionId);
if (existing) {
bindSession(existing.gooseSessionId, localSessionId, providerId);
// Update working dir if changed
if (existing.workingDir !== workingDir) {
await client.goose.gooseWorkingDirUpdate({ sessionId: existing.gooseSessionId, workingDir });
// ... update cache
}
// Update provider if changed
if (existing.providerId !== providerId) {
const response = await client.setSessionConfigOption({
sessionId: existing.gooseSessionId,
optionId: "provider",
value: providerId,
});
// ... emit model state from response.configOptions
}
return existing.gooseSessionId;
}
// Try to load existing session
let gooseSessionId: string | null = null;
try {
const loadResponse = await client.loadSession({
sessionId: localSessionId,
workingDir,
});
gooseSessionId = localSessionId;
bindSession(gooseSessionId, localSessionId, providerId);
// ... handle model state from loadResponse
// ... update provider if needed
} catch {
// Session doesn't exist — create new
}
if (!gooseSessionId) {
const meta: Record<string, unknown> = {};
if (providerId !== "goose") {
meta.provider = providerId;
}
const newResponse = await client.newSession({
workingDir,
...(Object.keys(meta).length > 0 ? { meta } : {}),
});
gooseSessionId = newResponse.sessionId;
bindSession(gooseSessionId, localSessionId, providerId);
// ... handle model state from newResponse
}
// Cache the prepared session
const prepared: PreparedSession = { gooseSessionId, providerId, workingDir };
preparedSessions.set(compositeKey, prepared);
preparedSessions.set(localSessionId, prepared);
return gooseSessionId;
}
```
### 2. `sendPrompt`
Port from `send_prompt_inner` in `prompt_ops.rs`:
```typescript
export async function sendPrompt(
sessionId: string,
providerId: string,
prompt: string,
options: {
workingDir?: string;
systemPrompt?: string;
personaId?: string;
personaName?: string;
images?: [string, string][]; // [base64, mimeType]
} = {},
): Promise<void> {
const client = await getClient();
const compositeKey = makeCompositeKey(sessionId, options.personaId);
// Build effective prompt with persona instructions
const effectivePrompt = buildEffectivePrompt(prompt, options.systemPrompt);
// Register running session
const abort = new AbortController();
const assistantMessageId = crypto.randomUUID();
runningSessions.set(compositeKey, {
compositeKey,
providerId,
startedAt: Date.now(),
assistantMessageId,
abortController: abort,
});
try {
// Prepare session (creates/loads goose session if needed)
const workingDir = options.workingDir ?? defaultArtifactsWorkingDir();
const gooseSessionId = await prepareSession(compositeKey, sessionId, providerId, workingDir);
// Attach writer in notification handler (so streaming events go to this message)
attachWriter(gooseSessionId, sessionId, providerId, assistantMessageId, options.personaId, options.personaName);
// Build content blocks
const content: ContentBlock[] = [{ type: "text", text: effectivePrompt }];
for (const [data, mimeType] of (options.images ?? [])) {
content.push({ type: "image", data, mimeType });
}
// Send the prompt — this blocks until the agent finishes
await client.prompt({
sessionId: gooseSessionId,
content,
});
// Finalize the message
clearWriter(gooseSessionId);
finalizeMessage(sessionId, assistantMessageId);
} catch (error) {
clearWriter(/* gooseSessionId */);
throw error;
} finally {
runningSessions.delete(compositeKey);
}
}
```
### 3. `cancelSession`
Port from `cancel_session_inner`:
```typescript
export async function cancelSession(sessionId: string, personaId?: string): Promise<boolean> {
const compositeKey = makeCompositeKey(sessionId, personaId);
const running = runningSessions.get(compositeKey);
// Find the goose session ID
const prepared = preparedSessions.get(compositeKey) ?? preparedSessions.get(sessionId);
if (!prepared) {
return running !== undefined; // still preparing
}
markCanceled(prepared.gooseSessionId);
try {
const client = await getClient();
await client.cancel({ sessionId: prepared.gooseSessionId });
} catch {
// Best-effort cancellation
}
return true;
}
```
### 4. `listSessions`
```typescript
export interface AcpSessionInfo {
sessionId: string;
title: string | null;
updatedAt: string | null;
messageCount: number;
}
export async function listSessions(): Promise<AcpSessionInfo[]> {
const client = await getClient();
const response = await client.unstable_listSessions({});
return response.sessions.map((info) => ({
sessionId: info.sessionId,
title: info.title ?? null,
updatedAt: info.updatedAt ?? null,
messageCount: (info.meta?.messageCount as number) ?? 0,
}));
}
```
### 5. `loadSession`
Port from `load_session_inner`:
```typescript
export async function loadSession(
localSessionId: string,
gooseSessionId: string,
workingDir: string,
): Promise<void> {
const client = await getClient();
// Start replay buffering
bindSession(gooseSessionId, localSessionId);
startReplayBuffer(localSessionId);
const response = await client.loadSession({
sessionId: gooseSessionId,
workingDir,
});
// The backend sends replay notifications asynchronously.
// We wait for the replay_complete signal (handled by the notification handler)
// which calls flushReplayBuffer().
// Handle model state from response
if (response.models) {
handleModelState(localSessionId, null, /* extract from response.models */);
}
if (response.configOptions) {
const modelState = extractModelOptionsFromConfigOptions(response.configOptions);
if (modelState) handleModelState(localSessionId, null, modelState);
}
// Register in prepared sessions cache
preparedSessions.set(localSessionId, {
gooseSessionId,
providerId: "goose", // will be updated on next prepare
workingDir,
});
}
```
### 6. `exportSession`, `importSession`, `forkSession`
```typescript
export async function exportSession(sessionId: string): Promise<string> {
const client = await getClient();
const result = await client.goose.gooseSessionExport({ sessionId });
return result.data;
}
export async function importSession(json: string): Promise<AcpSessionInfo> {
const client = await getClient();
return await client.goose.gooseSessionImport({ data: json });
}
export async function forkSession(sessionId: string): Promise<AcpSessionInfo> {
const client = await getClient();
const response = await client.unstable_forkSession({
sessionId,
workingDir: defaultArtifactsWorkingDir(),
});
return {
sessionId: response.sessionId,
title: (response.meta?.title as string) ?? null,
updatedAt: null,
messageCount: (response.meta?.messageCount as number) ?? 0,
};
}
```
### 7. `setModel`
```typescript
export async function setModel(localSessionId: string, modelId: string): Promise<void> {
const client = await getClient();
// Find all prepared sessions for this local session ID
for (const [key, prepared] of preparedSessions) {
const { sessionId } = splitCompositeKey(key);
if (sessionId !== localSessionId) continue;
const response = await client.setSessionConfigOption({
sessionId: prepared.gooseSessionId,
optionId: "model",
value: modelId,
});
// Emit model state from response
const modelState = extractModelOptionsFromConfigOptions(response.configOptions);
if (modelState) handleModelState(localSessionId, prepared.providerId, modelState);
}
}
```
### 8. `listProviders`
```typescript
const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]);
export interface AcpProvider {
id: string;
label: string;
}
export async function listProviders(): Promise<AcpProvider[]> {
const client = await getClient();
const result = await client.goose.gooseProvidersList({});
return result.providers
.filter((p: { id: string }) => !DEPRECATED_PROVIDER_IDS.has(p.id))
.map((p: { id: string; label: string }) => ({ id: p.id, label: p.label }));
}
```
### 9. `listRunning`
```typescript
export interface AcpRunningSession {
sessionId: string;
personaId: string | null;
providerId: string;
runningForSecs: number;
}
export function listRunning(): AcpRunningSession[] {
const now = Date.now();
return [...runningSessions.values()].map((entry) => {
const { sessionId, personaId } = splitCompositeKey(entry.compositeKey);
return {
sessionId,
personaId,
providerId: entry.providerId,
runningForSecs: Math.floor((now - entry.startedAt) / 1000),
};
});
}
```
### 10. `cancelAll`
```typescript
export function cancelAll(): void {
for (const entry of runningSessions.values()) {
entry.abortController.abort();
}
}
```
## Helper: Build Effective Prompt
Port the persona instruction wrapping from `mod.rs`:
```typescript
function buildEffectivePrompt(prompt: string, systemPrompt?: string): string {
if (!systemPrompt || systemPrompt.trim().length === 0) {
return prompt;
}
return [
`<persona-instructions>\n${systemPrompt}\n</persona-instructions>`,
`<user-message>\n${prompt}\n</user-message>`,
].join("\n\n");
}
```
## Helper: Default Artifacts Working Dir
```typescript
function defaultArtifactsWorkingDir(): string {
// This will be fetched from the Rust backend via getHomeDir()
// or cached at startup. For now, use a reasonable default.
return "~/.goose/artifacts";
}
```
Note: The Rust code resolves `~` to the actual home directory and creates the directory. In the TS version, the `goose serve` backend handles working directory resolution. We just need to pass a reasonable path.
## Imports from Other Modules
```typescript
import { getClient } from "./acpConnection";
import {
bindSession,
attachWriter,
clearWriter,
markCanceled,
startReplayBuffer,
finalizeReplay,
flushReplayBuffer,
finalizeMessage,
handleModelState,
extractModelOptionsFromConfigOptions,
} from "./acpNotificationHandler";
```
## Verification
1. `pnpm typecheck` passes.
2. `pnpm check` passes.
3. Unit tests for `makeCompositeKey`, `splitCompositeKey`, `buildEffectivePrompt`.
4. Port relevant tests from `session_ops/tests.rs`.
## Files Created
| File | Purpose |
|------|---------|
| `src/shared/api/acpSessionManager.ts` | Session state management and ACP operations |
## Dependencies
- Step 03 (`acpConnection.ts` — provides `getClient()`)
- Step 04 (`acpNotificationHandler.ts` — provides bind/attach/clear/finalize functions)
## Notes
- The Rust code uses per-session `Mutex` locks (`op_locks`) to prevent concurrent mutations. In single-threaded JS, this isn't needed for correctness, but you may want a simple promise-based lock to prevent concurrent `prepareSession` calls for the same composite key from racing.
- The Rust `pending_cancels` / `preparing_sessions` sets coordinate cancellation during preparation. Port these as simple `Set<string>` module-level variables.
- The `GooseExtClient` methods (e.g., `client.goose.gooseProvidersList()`) are generated from the ACP schema. Check the actual method names in `ui/acp/src/generated/client.gen.ts` — they may use camelCase versions of the `goose/providers/list` method name.
- The `client.prompt()` call blocks until the agent finishes responding. During this time, `SessionNotification` events stream in via the `Client` callback (handled by the notification handler). This is the same flow as the Rust code.
@@ -0,0 +1,363 @@
# Step 06: Port Session Content Search to TypeScript
## Objective
Port the session content search logic from `src-tauri/src/services/acp/search.rs` to TypeScript. This is pure text processing on exported JSON — no native access needed.
## Why
The Rust search code:
1. Exports each session as JSON via the ACP `goose/session/export` extension method
2. Parses the JSON to extract user/assistant/system messages
3. Performs case-insensitive substring matching
4. Builds snippets around the first match
All of this is string processing that runs fine in JavaScript. Moving it to TypeScript eliminates the Rust→TS round-trip for each session export during search.
## New File
### `src/features/sessions/lib/sessionContentSearch.ts`
```typescript
/**
* Search session message content via exported Goose sessions.
*
* Ported from src-tauri/src/services/acp/search.rs
*/
import { exportSession } from "@/shared/api/acpSessionManager"; // from Step 05
const SNIPPET_PREFIX_BYTES = 40;
const SNIPPET_SUFFIX_BYTES = 60;
export interface SessionSearchResult {
sessionId: string;
snippet: string;
messageId: string;
messageRole?: "user" | "assistant" | "system";
matchCount: number;
}
```
## Functions to Port
### 1. `searchSessionsViaExports`
The top-level function that iterates over session IDs, exports each, and searches:
```typescript
export async function searchSessionsViaExports(
query: string,
sessionIds: string[],
): Promise<SessionSearchResult[]> {
const trimmed = query.trim();
if (!trimmed) return [];
const seen = new Set<string>();
const results: SessionSearchResult[] = [];
for (const sessionId of sessionIds) {
if (seen.has(sessionId)) continue;
seen.add(sessionId);
try {
const exported = await exportSession(sessionId);
const result = searchExportedSession(sessionId, exported, trimmed);
if (result) results.push(result);
} catch {
// Skip sessions that fail to export
}
}
return results;
}
```
### 2. `searchExportedSession`
```typescript
function searchExportedSession(
sessionId: string,
exportedJson: string,
query: string,
): SessionSearchResult | null {
let root: unknown;
try {
root = JSON.parse(exportedJson);
} catch {
return null;
}
const obj = root as Record<string, unknown>;
const conversation = obj.conversation ?? obj.messages;
if (!conversation) return null;
const messages = extractMessages(conversation);
if (messages.length === 0) return null;
let firstMatch: { messageId: string; role: string | null; snippet: string } | null = null;
let matchCount = 0;
for (const message of messages) {
for (const text of message.searchableTexts) {
const occurrences = countOccurrences(text, query);
if (occurrences === 0) continue;
matchCount += occurrences;
if (!firstMatch) {
firstMatch = {
messageId: message.id,
role: message.role,
snippet: buildSnippet(text, query),
};
}
}
}
if (!firstMatch) return null;
return {
sessionId,
snippet: firstMatch.snippet,
messageId: firstMatch.messageId,
messageRole: firstMatch.role as SessionSearchResult["messageRole"],
matchCount,
};
}
```
### 3. `extractMessages`
Recursively walks the JSON structure to find message objects:
```typescript
interface ExportedMessage {
id: string;
role: string | null;
searchableTexts: string[];
}
function extractMessages(value: unknown): ExportedMessage[] {
const messages: ExportedMessage[] = [];
collectMessages(value, messages);
return messages;
}
function collectMessages(value: unknown, messages: ExportedMessage[]): void {
if (Array.isArray(value)) {
for (const item of value) {
collectMessages(item, messages);
}
return;
}
if (typeof value !== "object" || value === null) return;
const obj = value as Record<string, unknown>;
if (obj.message !== undefined) {
collectMessages(obj.message, messages);
return;
}
if (obj.messages !== undefined) {
collectMessages(obj.messages, messages);
return;
}
if (looksLikeMessage(obj)) {
const fallbackId = `message-${messages.length}`;
const message = extractMessage(obj, fallbackId);
if (message) messages.push(message);
}
}
function looksLikeMessage(obj: Record<string, unknown>): boolean {
return "role" in obj && ("content" in obj || "text" in obj);
}
```
### 4. `extractMessage`
```typescript
function extractMessage(
obj: Record<string, unknown>,
fallbackId: string,
): ExportedMessage | null {
const role = normalizeRole(obj.role as string | undefined);
const searchableTexts: string[] = [];
if (obj.content !== undefined) {
searchableTexts.push(...extractSearchableTexts(obj.content, role));
} else if (typeof obj.text === "string") {
if (role && obj.text.trim().length > 0) {
searchableTexts.push(obj.text.trim());
}
}
if (searchableTexts.length === 0) return null;
return {
id: typeof obj.id === "string" ? obj.id : fallbackId,
role,
searchableTexts,
};
}
```
### 5. `extractSearchableTexts`
```typescript
function extractSearchableTexts(value: unknown, role: string | null): string[] {
if (typeof value === "string") {
if (role && isSearchableRole(role)) {
const trimmed = value.trim();
return trimmed.length > 0 ? [trimmed] : [];
}
return [];
}
if (Array.isArray(value)) {
return value.flatMap((item) => extractSearchableBlockText(item, role));
}
if (typeof value === "object" && value !== null) {
return extractSearchableBlockText(value, role);
}
return [];
}
function extractSearchableBlockText(value: unknown, role: string | null): string[] {
if (typeof value !== "object" || value === null) return [];
const obj = value as Record<string, unknown>;
const blockType = obj.type as string | undefined;
const text = obj.text as string | undefined;
switch (blockType) {
case "text":
case "input_text":
case "output_text":
case "systemNotification":
case "system_notification": {
const trimmed = text?.trim();
return trimmed && trimmed.length > 0 ? [trimmed] : [];
}
case "toolRequest":
case "toolResponse":
case "thinking":
case "redactedThinking":
case "reasoning":
case "image":
return [];
default: {
if (role && isSearchableRole(role)) {
const trimmed = text?.trim();
return trimmed && trimmed.length > 0 ? [trimmed] : [];
}
return [];
}
}
}
```
### 6. Helper functions
```typescript
function normalizeRole(role: string | undefined): string | null {
if (!role) return null;
const trimmed = role.trim().toLowerCase();
if (trimmed === "user") return "user";
if (trimmed === "assistant") return "assistant";
if (trimmed === "system") return "system";
return null;
}
function isSearchableRole(role: string): boolean {
return role === "user" || role === "assistant" || role === "system";
}
function countOccurrences(text: string, query: string): number {
const haystack = text.toLowerCase();
const needle = query.toLowerCase();
if (needle.length === 0) return 0;
let count = 0;
let searchStart = 0;
while (true) {
const index = haystack.indexOf(needle, searchStart);
if (index === -1) break;
count += 1;
searchStart = index + needle.length;
}
return count;
}
function buildSnippet(text: string, query: string): string {
const haystack = text.toLowerCase();
const needle = query.toLowerCase();
const matchIndex = haystack.indexOf(needle);
const effectiveMatchIndex = matchIndex >= 0 ? matchIndex : 0;
const start = Math.max(0, effectiveMatchIndex - SNIPPET_PREFIX_BYTES);
const end = Math.min(
text.length,
effectiveMatchIndex + query.length + SNIPPET_SUFFIX_BYTES,
);
const prefix = start > 0 ? "..." : "";
const suffix = end < text.length ? "..." : "";
const body = text.substring(start, end).trim();
return `${prefix}${body}${suffix}`;
}
```
## Tests to Port
Port the tests from `search.rs` `mod tests`:
```typescript
// src/features/sessions/lib/__tests__/sessionContentSearch.test.ts
import { describe, it, expect } from "vitest";
// Import the internal functions (may need to export them for testing)
describe("sessionContentSearch", () => {
it("finds user and assistant text matches", () => { /* ... */ });
it("includes system notifications", () => { /* ... */ });
it("skips tool and reasoning content", () => { /* ... */ });
it("counts multiple matches in one session", () => { /* ... */ });
it("builds trimmed snippets around first match", () => { /* ... */ });
// ... port all tests from search.rs
});
```
## Integration with `useSessionSearch`
The existing `useSessionSearch` hook calls `acpSearchSessions()` from `@/shared/api/acp`. In Step 07, that function will be rewired to call `searchSessionsViaExports` from this module instead of `invoke("acp_search_sessions")`.
## Verification
1. `pnpm typecheck` passes.
2. `pnpm check` passes.
3. `pnpm test` — all ported search tests pass.
## Files Created
| File | Purpose |
|------|---------|
| `src/features/sessions/lib/sessionContentSearch.ts` | Session content search logic |
| `src/features/sessions/lib/__tests__/sessionContentSearch.test.ts` | Tests |
## Dependencies
- Step 05 (`acpSessionManager.ts` — provides `exportSession()`)
## Notes
- The Rust `build_snippet` uses byte-level `floor_char_boundary` / `ceil_char_boundary` for UTF-8 safety. In JavaScript, `String.substring()` operates on UTF-16 code units and is already safe for slicing. The snippet boundaries may differ slightly from the Rust version for multi-byte characters, but this is cosmetic.
- The Rust search is sequential (one session at a time). The TS version could parallelize with `Promise.all` for better performance, but sequential is simpler and matches the Rust behavior. Consider parallelizing later if search is slow.
- The `exportSession` call goes through the ACP client to `goose serve`, which reads from its database. This is the same path as the Rust code — no change in data source.
@@ -0,0 +1,248 @@
# Step 07: Rewire `src/shared/api/acp.ts` to Use the TypeScript ACP Client
## Objective
Replace all `invoke()` calls in `src/shared/api/acp.ts` with calls to the new TypeScript ACP session manager (Step 05) and search module (Step 06). Keep the same public API signatures so consumers don't need to change.
## Why
`src/shared/api/acp.ts` is the single import point for all ACP operations in the frontend. Currently every function calls `invoke("acp_*")` which goes through Tauri IPC → Rust → WebSocket → goose serve. After this step, they call the TypeScript session manager which goes directly through HTTP+SSE → goose serve.
## Changes
### `src/shared/api/acp.ts`
Replace the entire file contents. The public API (function names, parameter types, return types) stays the same. Only the implementation changes.
**Before (current):**
```typescript
import { invoke } from "@tauri-apps/api/core";
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
return invoke("discover_acp_providers");
}
export async function acpSendMessage(sessionId, providerId, prompt, options): Promise<void> {
return invoke("acp_send_message", { ... });
}
// ... etc
```
**After:**
```typescript
import {
listProviders,
sendPrompt,
prepareSession,
setModel,
cancelSession,
listSessions,
loadSession,
exportSession,
importSession,
forkSession,
listRunning,
cancelAll,
} from "./acpSessionManager";
import { searchSessionsViaExports } from "@/features/sessions/lib/sessionContentSearch";
// Re-export types (unchanged)
export type { AcpProvider, AcpSessionInfo, AcpRunningSession } from "./acpSessionManager";
export type { SessionSearchResult as AcpSessionSearchResult } from "@/features/sessions/lib/sessionContentSearch";
```
### Function-by-function rewiring
#### `discoverAcpProviders`
```typescript
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
return listProviders();
}
```
#### `acpSendMessage`
```typescript
export async function acpSendMessage(
sessionId: string,
providerId: string,
prompt: string,
options: AcpSendMessageOptions = {},
): Promise<void> {
return sendPrompt(sessionId, providerId, prompt, {
workingDir: options.workingDir,
systemPrompt: options.systemPrompt,
personaId: options.personaId,
personaName: options.personaName,
images: options.images,
});
}
```
#### `acpPrepareSession`
```typescript
export async function acpPrepareSession(
sessionId: string,
providerId: string,
options: AcpPrepareSessionOptions = {},
): Promise<void> {
const { makeCompositeKey } = await import("./acpSessionManager");
const compositeKey = makeCompositeKey(sessionId, options.personaId);
const workingDir = options.workingDir ?? "~/.goose/artifacts";
await prepareSession(compositeKey, sessionId, providerId, workingDir);
}
```
#### `acpSetModel`
```typescript
export async function acpSetModel(
sessionId: string,
modelId: string,
): Promise<void> {
return setModel(sessionId, modelId);
}
```
#### `acpListSessions`
```typescript
export async function acpListSessions(): Promise<AcpSessionInfo[]> {
return listSessions();
}
```
#### `acpSearchSessions`
```typescript
export async function acpSearchSessions(
query: string,
sessionIds: string[],
): Promise<AcpSessionSearchResult[]> {
return searchSessionsViaExports(query, sessionIds);
}
```
#### `acpLoadSession`
```typescript
export async function acpLoadSession(
sessionId: string,
gooseSessionId: string,
workingDir?: string,
): Promise<void> {
return loadSession(sessionId, gooseSessionId, workingDir ?? "~/.goose/artifacts");
}
```
#### `acpExportSession`
```typescript
export async function acpExportSession(sessionId: string): Promise<string> {
return exportSession(sessionId);
}
```
#### `acpImportSession`
```typescript
export async function acpImportSession(json: string): Promise<AcpSessionInfo> {
return importSession(json);
}
```
#### `acpDuplicateSession`
```typescript
export async function acpDuplicateSession(sessionId: string): Promise<AcpSessionInfo> {
return forkSession(sessionId);
}
```
#### `acpCancelSession`
```typescript
export async function acpCancelSession(
sessionId: string,
personaId?: string,
): Promise<boolean> {
return cancelSession(sessionId, personaId);
}
```
### Keep the interface types
The `AcpSendMessageOptions`, `AcpPrepareSessionOptions`, `AcpSessionInfo`, `AcpSessionSearchResult`, `AcpProvider` interfaces should remain exported from this file (or re-exported from the session manager). Consumers import them from `@/shared/api/acp`.
If the types are now defined in `acpSessionManager.ts` or `sessionContentSearch.ts`, re-export them:
```typescript
export type { AcpProvider, AcpSessionInfo } from "./acpSessionManager";
export type { SessionSearchResult as AcpSessionSearchResult } from "@/features/sessions/lib/sessionContentSearch";
// Keep these interfaces here since they're specific to this API surface
export interface AcpSendMessageOptions {
systemPrompt?: string;
workingDir?: string;
personaId?: string;
personaName?: string;
images?: [string, string][];
}
export interface AcpPrepareSessionOptions {
workingDir?: string;
personaId?: string;
}
```
### Update `src/shared/api/index.ts`
No changes needed — it already re-exports from `./acp`:
```typescript
export * from "./acp";
```
## Consumers That Import from `@/shared/api/acp`
Verify these files still work without changes (they should, since the public API is unchanged):
| File | Imports Used |
|------|-------------|
| `src/features/chat/hooks/useChat.ts` | `acpSendMessage`, `acpCancelSession`, `acpPrepareSession`, `acpSetModel` |
| `src/features/chat/stores/chatSessionStore.ts` | `acpListSessions`, `AcpSessionInfo` |
| `src/features/sessions/hooks/useSessionSearch.ts` | `acpSearchSessions` |
| `src/features/sessions/lib/buildSessionSearchResults.ts` | `AcpSessionSearchResult` |
| `src/app/AppShell.tsx` | `acpPrepareSession`, `acpLoadSession` |
| `src/app/hooks/useAppStartup.ts` | `discoverAcpProviders` |
## Remove `invoke` Import
The file should no longer import from `@tauri-apps/api/core` since all `invoke()` calls are replaced.
## Verification
1. `pnpm typecheck` passes — all consumers still type-check against the same API.
2. `pnpm check` passes.
3. `pnpm test` passes — existing tests that mock `invoke()` may need updating. Check `src/features/chat/hooks/__tests__/useAcpStream.test.ts` and `src/features/chat/hooks/__tests__/useChat.test.ts`.
4. Manual testing: start the app, verify sessions load, messages send, search works.
## Files Modified
| File | Change |
|------|--------|
| `src/shared/api/acp.ts` | Replace all `invoke()` calls with session manager / search calls |
## Dependencies
- Step 05 (`acpSessionManager.ts` — all session operations)
- Step 06 (`sessionContentSearch.ts` — search)
## Notes
- This is the "flip the switch" step. After this, the frontend no longer calls any `acp_*` Tauri commands (except `get_goose_serve_url` which is called by `acpConnection.ts`).
- The old Rust ACP commands still exist and are registered but are no longer called. They'll be removed in Step 09.
- If you want a gradual rollout, you could add a feature flag that switches between the old `invoke()` path and the new direct path. But since the public API is identical, it's simpler to just swap.
- The `@tauri-apps/api/core` import can be removed from this file entirely. Other files (agents, git, system, etc.) still use `invoke()` for non-ACP commands.
@@ -0,0 +1,259 @@
# Step 08: Remove `useAcpStream`, Update Hooks and App Initialization
## Objective
Remove the `useAcpStream` hook (which listens to Tauri events) since the notification handler (Step 04) now updates stores directly. Update app initialization to set up the new ACP connection and notification handler. Update `useChat` and `AppShell` to use the new code paths.
## Why
With the notification handler updating Zustand stores directly from ACP callbacks, the Tauri event bus is no longer in the loop. The `useAcpStream` hook — which listens to `acp:text`, `acp:done`, `acp:tool_call`, etc. — is now dead code.
## Changes
### 1. Remove `useAcpStream` from `AppShell`
**File:** `src/app/AppShell.tsx`
Remove the import and call:
```diff
- import { useAcpStream } from "@/features/chat/hooks/useAcpStream";
// Inside the component:
- useAcpStream(true);
```
### 2. Initialize the ACP connection and notification handler on startup
**File:** `src/app/hooks/useAppStartup.ts`
Add ACP initialization as the first step. The notification handler must be registered before any ACP calls are made (so that session notifications from `loadSessions` are handled).
```typescript
import { useEffect } from "react";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
export function useAppStartup() {
useEffect(() => {
(async () => {
// Step 1: Initialize ACP connection and notification handler.
// This must happen before any ACP calls.
try {
const { getClient, setNotificationHandler } = await import(
"@/shared/api/acpConnection"
);
const notificationHandler = await import(
"@/shared/api/acpNotificationHandler"
);
setNotificationHandler(notificationHandler);
// Trigger connection initialization (fetches URL, creates client, handshake).
// This blocks until goose serve is ready.
await getClient();
} catch (err) {
console.error("Failed to initialize ACP connection:", err);
// The app can still show the UI, but ACP operations will fail.
// Individual operations will retry getClient() and show errors.
}
// Step 2: Load data in parallel (same as before, but now using the TS ACP client)
const store = useAgentStore.getState();
const loadPersonas = async () => {
store.setPersonasLoading(true);
try {
const { listPersonas } = await import("@/shared/api/agents");
const personas = await listPersonas();
store.setPersonas(personas);
} catch (err) {
console.error("Failed to load personas on startup:", err);
} finally {
store.setPersonasLoading(false);
}
};
const loadProviders = async () => {
store.setProvidersLoading(true);
try {
const { discoverAcpProviders } = await import("@/shared/api/acp");
const providers = await discoverAcpProviders();
store.setProviders(providers);
} catch (err) {
console.error("Failed to load ACP providers on startup:", err);
} finally {
store.setProvidersLoading(false);
}
};
const loadSessionState = async () => {
const t0 = performance.now();
console.log("[perf:startup] loadSessionState start");
const { loadSessions, setActiveSession } =
useChatSessionStore.getState();
await loadSessions();
console.log(
`[perf:startup] loadSessions done in ${(performance.now() - t0).toFixed(1)}ms`,
);
setActiveSession(null);
};
await Promise.allSettled([
loadPersonas(),
loadProviders(),
loadSessionState(),
]);
})();
}, []);
}
```
### 3. Update `AppShell.loadSessionMessages`
**File:** `src/app/AppShell.tsx`
The `loadSessionMessages` callback currently dynamically imports `acpLoadSession` from `@/shared/api/acp`. This still works because Step 07 rewired that function. **No changes needed** to this callback — it already calls `acpLoadSession()` which now goes through the TS session manager.
However, verify the import path is correct:
```typescript
const { acpLoadSession } = await import("@/shared/api/acp");
```
This should still work since `acpLoadSession` is still exported from `@/shared/api/acp` (Step 07 kept the same exports).
### 4. Update `useChat` — no changes needed
**File:** `src/features/chat/hooks/useChat.ts`
This hook imports `acpSendMessage`, `acpCancelSession`, `acpPrepareSession`, `acpSetModel` from `@/shared/api/acp`. Since Step 07 kept the same API, **no changes are needed** in this file.
Verify the imports still resolve:
```typescript
import {
acpSendMessage,
acpCancelSession,
acpPrepareSession,
acpSetModel,
} from "@/shared/api/acp";
```
### 5. Handle app shutdown
**File:** `src/app/AppShell.tsx` or `src/app/App.tsx`
Add cleanup on window close to cancel running sessions:
```typescript
import { useEffect } from "react";
useEffect(() => {
const handleBeforeUnload = () => {
// Best-effort cancel all running sessions
import("@/shared/api/acpSessionManager").then(({ cancelAll }) => {
cancelAll();
}).catch(() => {});
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
```
The Rust backend also cancels all sessions on `RunEvent::Exit` via `acp_registry_for_exit.cancel_all()`. After migration, the Rust registry is empty (no sessions are registered there), so the Rust cleanup is a no-op. The TS cleanup above replaces it.
### 6. Delete or deprecate old files
These files are no longer needed after this step:
**Delete:**
- `src/features/chat/hooks/useAcpStream.ts` — replaced by `acpNotificationHandler.ts`
- `src/features/chat/hooks/acpStreamTypes.ts` — types are now in the notification handler or imported from the SDK
- `src/features/chat/hooks/replayBuffer.ts` — logic moved into the notification handler
**Keep but verify:**
- `src/features/chat/hooks/useSSE.ts` — check if anything else uses it. If it was only used by `useAcpStream`, delete it.
### 7. Update test files
**Files to update or delete:**
- `src/features/chat/hooks/__tests__/useAcpStream.test.ts` — **Delete** (the hook no longer exists)
- `src/features/chat/hooks/__tests__/useChat.test.ts` — **Update** if it mocks `invoke()` for ACP commands. The mocks should now target the session manager functions instead.
For `useChat.test.ts`, if it currently mocks like:
```typescript
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
```
It should instead mock:
```typescript
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: vi.fn().mockResolvedValue(undefined),
acpPrepareSession: vi.fn().mockResolvedValue(undefined),
acpSetModel: vi.fn().mockResolvedValue(undefined),
acpCancelSession: vi.fn().mockResolvedValue(true),
}));
```
This may already be the case if the tests mock at the `@/shared/api/acp` level rather than `invoke()` directly.
### 8. Remove Tauri event listener cleanup
The `useAcpStream` hook registered listeners for `acp:text`, `acp:done`, `acp:tool_call`, `acp:tool_title`, `acp:tool_result`, `acp:message_created`, `acp:session_info`, `acp:session_bound`, `acp:model_state`, `acp:usage_update`, `acp:replay_complete`, `acp:replay_user_message`. These are all gone now.
No other code should be listening to these events. Verify with a search:
```bash
cd ui/goose2/src
rg "acp:" --include="*.ts" --include="*.tsx" | grep -v "__tests__" | grep -v "node_modules"
```
After this step, the only `acp:` references should be in test files (which should be updated/deleted) and possibly in the notification handler (which doesn't use Tauri events).
## Verification
1. `pnpm typecheck` passes.
2. `pnpm check` passes.
3. `pnpm test` passes (after updating/deleting affected tests).
4. Manual testing:
- App starts and shows the home screen
- Session list loads
- Creating a new chat and sending a message works
- Streaming text appears in real-time
- Tool calls display correctly
- Cancelling a running session works
- Loading a historical session replays messages
- Session search returns results
- Model switching works
- Session export/import/duplicate works
## Files Modified
| File | Change |
|------|--------|
| `src/app/AppShell.tsx` | Remove `useAcpStream(true)`, add shutdown cleanup |
| `src/app/hooks/useAppStartup.ts` | Add ACP connection + notification handler initialization |
## Files Deleted
| File | Reason |
|------|--------|
| `src/features/chat/hooks/useAcpStream.ts` | Replaced by `acpNotificationHandler.ts` |
| `src/features/chat/hooks/acpStreamTypes.ts` | Types moved to notification handler |
| `src/features/chat/hooks/replayBuffer.ts` | Logic moved to notification handler |
| `src/features/chat/hooks/__tests__/useAcpStream.test.ts` | Hook deleted |
## Dependencies
- Step 03 (`acpConnection.ts`)
- Step 04 (`acpNotificationHandler.ts`)
- Step 07 (rewired `acp.ts`)
## Notes
- The `useAcpStream` hook was the **only** consumer of the `acp:*` Tauri events. Once it's removed, no frontend code listens to those events. The Rust backend will still emit them (until Step 09 removes the Rust code), but they'll go nowhere — this is harmless.
- The `useChat` hook's `sendMessage` function currently sets `chatState` to `"thinking"` before calling `acpPrepareSession`, then to `"streaming"` before `acpSendMessage`. This flow is unchanged — the session manager handles the ACP calls, and the notification handler updates the store as streaming events arrive.
- The `stopGeneration` function in `useChat` calls `acpCancelSession` — this now goes through the TS session manager which calls `client.cancel()` directly.
- The `loadSessionMessages` callback in `AppShell` sets `store.setSessionLoading(sessionId, true)` before calling `acpLoadSession`. The notification handler's replay logic checks `loadingSessionIds` to decide whether to buffer. This flow is preserved — the notification handler reads from `useChatStore.getState().loadingSessionIds` just like `useAcpStream` did.
@@ -0,0 +1,393 @@
# Step 09: Delete the Rust ACP Middleware and Unused Dependencies
## Objective
Remove all Rust ACP protocol handling code that is no longer called by the frontend. This is the cleanup step — only do this after Steps 01–08 are working and tested.
## Why
After Steps 01–08, the frontend communicates directly with `goose serve` via HTTP+SSE. The Rust ACP middleware (WebSocket bridge, session dispatcher, message writer, session registry, search) is dead code. Removing it:
- Eliminates ~3,500 lines of Rust
- Removes 5–6 heavy crate dependencies
- Reduces compile times
- Simplifies the codebase
## Changes
### 1. Delete the ACP manager subtree
**Delete these files entirely:**
```
src-tauri/src/services/acp/manager/
command_dispatch.rs
dispatcher.rs
dispatcher_tests.rs
session_ops.rs
session_ops/
prompt_ops.rs
tests.rs
thread.rs
```
**Delete these files:**
```
src-tauri/src/services/acp/manager.rs
src-tauri/src/services/acp/writer.rs
src-tauri/src/services/acp/payloads.rs
src-tauri/src/services/acp/registry.rs
src-tauri/src/services/acp/search.rs
```
### 2. Simplify `services/acp/mod.rs`
**File:** `src-tauri/src/services/acp/mod.rs`
Replace the entire file with:
```rust
pub(crate) mod goose_serve;
pub(crate) use goose_serve::GooseServeProcess;
```
All the old re-exports (`GooseAcpManager`, `AcpSessionRegistry`, `TauriMessageWriter`, `search_sessions_via_exports`, `make_composite_key`, `split_composite_key`, `AcpService`, `AcpRunningSession`, `AcpSessionInfo`, `SessionSearchResult`) are removed.
### 3. Simplify `commands/acp.rs`
**File:** `src-tauri/src/commands/acp.rs`
Replace the entire file with just the URL command:
```rust
use crate::services::acp::GooseServeProcess;
/// Return the HTTP base URL of the running goose serve process.
///
/// This command blocks until the server is confirmed ready. The frontend
/// uses this URL to establish a direct HTTP+SSE ACP connection.
#[tauri::command]
pub async fn get_goose_serve_url() -> Result<String, String> {
GooseServeProcess::start().await?;
let process = GooseServeProcess::get()?;
Ok(process.http_url())
}
```
All other ACP commands are deleted:
- `discover_acp_providers`
- `acp_prepare_session`
- `acp_set_model`
- `acp_send_message`
- `acp_cancel_session`
- `acp_list_sessions`
- `acp_search_sessions`
- `acp_load_session`
- `acp_list_running`
- `acp_cancel_all`
- `acp_export_session`
- `acp_import_session`
- `acp_duplicate_session`
Also delete the helper functions that were only used by those commands:
- `AcpProviderResponse` struct
- `should_include_provider`
- `default_artifacts_working_dir`
- `expand_home_dir`
- `resolve_working_dir`
- The `#[cfg(test)] mod tests` block (the tests tested those helpers)
### 4. Update `lib.rs`
**File:** `src-tauri/src/lib.rs`
Remove the `AcpSessionRegistry` from managed state and remove all old ACP command registrations.
**Before:**
```rust
use std::sync::Arc;
use services::acp::AcpSessionRegistry;
// ...
let acp_registry = Arc::new(AcpSessionRegistry::new());
let acp_registry_for_exit = Arc::clone(&acp_registry);
let builder = tauri::Builder::default()
// ...
.manage(acp_registry);
// In invoke_handler:
commands::acp::discover_acp_providers,
commands::acp::acp_prepare_session,
commands::acp::acp_set_model,
commands::acp::acp_send_message,
commands::acp::acp_cancel_session,
commands::acp::acp_list_sessions,
commands::acp::acp_search_sessions,
commands::acp::acp_load_session,
commands::acp::acp_list_running,
commands::acp::acp_cancel_all,
commands::acp::acp_export_session,
commands::acp::acp_import_session,
commands::acp::acp_duplicate_session,
// In run closure:
.run(move |_app, event| {
if let tauri::RunEvent::Exit = event {
acp_registry_for_exit.cancel_all();
}
});
```
**After:**
```rust
// Remove: use std::sync::Arc;
// Remove: use services::acp::AcpSessionRegistry;
// Remove: let acp_registry = ...
// Remove: let acp_registry_for_exit = ...
let builder = tauri::Builder::default()
// ...
// Remove: .manage(acp_registry)
;
// In invoke_handler, replace all old ACP commands with just:
commands::acp::get_goose_serve_url,
// Simplify the run closure:
.run(|_app, _event| {});
```
The `Arc` import can be removed if nothing else uses it. Check if `PersonaStore` or `GooseConfig` need it — they don't (they use `tauri::State` which handles the wrapping).
### 5. Clean up `goose_serve.rs`
**File:** `src-tauri/src/services/acp/goose_serve.rs`
The file is mostly kept as-is. Make these changes:
1. Add the `http_url()` method (from Step 01):
```rust
impl GooseServeProcess {
pub fn http_url(&self) -> String {
format!("http://{LOCALHOST}:{}", self.port)
}
}
```
2. Remove the `WS_BRIDGE_BUFFER_BYTES` constant — it was only used by the WebSocket bridge in `thread.rs`:
```rust
// DELETE:
pub(crate) const WS_BRIDGE_BUFFER_BYTES: usize = 64 * 1024;
```
3. The `resolve_goose_binary` function is still needed by `model_setup.rs` (which runs `goose configure`). Keep it exported as `pub(crate)`.
### 6. Remove unused Cargo dependencies
**File:** `src-tauri/Cargo.toml`
Remove these dependencies that were only used by the deleted ACP code:
```toml
# DELETE these lines:
agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] }
acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" }
tokio-tungstenite = "0.21.0"
async-trait = "0.1"
```
**Check before removing — these may still be used by remaining code:**
- `futures = "0.3"` — check if any remaining code uses it. `goose_serve.rs` uses `futures::{SinkExt, StreamExt}` for the WebSocket readiness probe. After removing the WS probe... wait, the readiness probe in `wait_for_server_ready` uses `tokio-tungstenite` and `futures`. We need to replace the readiness check.
**Replace the WebSocket readiness probe with an HTTP health check:**
The `wait_for_server_ready` function currently connects via WebSocket to check if the server is up. Replace it with a simple HTTP GET to `/health`:
```rust
async fn wait_for_server_ready(base_url: &str, child: &mut Child) -> Result<(), String> {
let deadline = Instant::now() + GOOSE_SERVE_CONNECT_TIMEOUT;
let health_url = format!("{base_url}/health");
loop {
match reqwest::get(&health_url).await {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(_) | Err(_) => {
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 {health_url}"
));
}
tokio::time::sleep(GOOSE_SERVE_CONNECT_RETRY_DELAY).await;
}
}
}
}
```
Wait — adding `reqwest` is a new dependency. A simpler approach: use `tokio::net::TcpStream::connect` to check if the port is open:
```rust
async fn wait_for_server_ready(port: u16, child: &mut Child) -> Result<(), String> {
let deadline = Instant::now() + GOOSE_SERVE_CONNECT_TIMEOUT;
let addr = format!("{LOCALHOST}:{port}");
loop {
match tokio::net::TcpStream::connect(&addr).await {
Ok(_) => return Ok(()),
Err(_) => {
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 on port {port}"
));
}
tokio::time::sleep(GOOSE_SERVE_CONNECT_RETRY_DELAY).await;
}
}
}
}
```
This only needs `tokio` (already a dependency). Update the `spawn` method to call `wait_for_server_ready(port, &mut child)` instead of `wait_for_server_ready(&ws_url, &mut child)`.
With this change, `futures`, `tokio-tungstenite` can be removed.
- `tokio-util = { version = "0.7", features = ["compat", "rt"] }` — check if anything else uses it. It was used by `thread.rs` for `TokioAsyncReadCompatExt`/`TokioAsyncWriteCompatExt`. If nothing else uses it, remove it.
After all removals, the remaining dependencies should be:
```toml
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-app-test-driver = { path = "plugins/app-test-driver" }
tauri-plugin-opener = "2"
tauri-plugin-dialog = ">=2,<2.7"
tauri-plugin-window-state = "2"
tauri-plugin-log = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6.0.0"
log = "0.4.29"
tokio = { version = "1.50.0", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
serde_yaml = "0.9"
etcetera = "0.8"
ignore = "0.4.25"
doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" }
keyring = { ... } # platform-specific
```
Use `cargo remove` or edit `Cargo.toml` directly, then run `cargo check` to verify nothing breaks.
### 7. Run full verification
```bash
cd ui/goose2/src-tauri
# Format
cargo fmt
# Check compilation
cargo check
# Clippy
cargo clippy --all-targets -- -D warnings
# Run any remaining Rust tests
cargo test
```
Then from the `ui/goose2` directory:
```bash
source ./bin/activate-hermit
just check
just test
just tauri-check
```
## Summary of Deletions
| Path | Lines | Purpose (was) |
|------|-------|---------------|
| `services/acp/manager/command_dispatch.rs` | ~258 | Command dispatch loop |
| `services/acp/manager/dispatcher.rs` | ~532 | Session event dispatcher + Client trait impl |
| `services/acp/manager/dispatcher_tests.rs` | ~28 | Dispatcher tests |
| `services/acp/manager/session_ops.rs` | ~611 | Session prepare/load/cancel/set-model |
| `services/acp/manager/session_ops/prompt_ops.rs` | ~(inline) | Send prompt logic |
| `services/acp/manager/session_ops/tests.rs` | ~(inline) | Session ops tests |
| `services/acp/manager/thread.rs` | ~169 | Manager thread + WebSocket bridge |
| `services/acp/manager.rs` | ~308 | GooseAcpManager struct + ManagerCommand enum |
| `services/acp/writer.rs` | ~156 | TauriMessageWriter |
| `services/acp/payloads.rs` | ~106 | Tauri event payload structs |
| `services/acp/registry.rs` | ~114 | AcpSessionRegistry |
| `services/acp/search.rs` | ~467 | Session content search |
| **Total** | **~2,749** | |
Plus significant simplification of `commands/acp.rs` (~330 → ~15 lines) and `services/acp/mod.rs` (~147 → ~4 lines) and `lib.rs` (~114 → ~80 lines).
## Cargo Dependencies Removed
| Crate | Why it was needed |
|-------|-------------------|
| `agent-client-protocol` | Rust ACP client types (Agent, ClientSideConnection, etc.) |
| `acp-client` | Agent discovery, MessageWriter trait |
| `tokio-tungstenite` | WebSocket connection to goose serve |
| `async-trait` | MessageWriter + Client trait impls |
| `futures` | WebSocket stream splitting (SinkExt, StreamExt) |
| `tokio-util` | Compat adapters for async read/write |
## Files Modified
| File | Change |
|------|--------|
| `src-tauri/src/services/acp/mod.rs` | Simplified to just goose_serve re-export |
| `src-tauri/src/services/acp/goose_serve.rs` | Add `http_url()`, remove WS constants, replace readiness probe |
| `src-tauri/src/commands/acp.rs` | Replaced with single `get_goose_serve_url` command |
| `src-tauri/src/lib.rs` | Remove AcpSessionRegistry, old ACP commands, simplify run closure |
| `src-tauri/Cargo.toml` | Remove 6 dependencies |
| `src-tauri/Cargo.lock` | Auto-updated |
## Files Deleted
All files listed in the "Summary of Deletions" table above.
## Dependencies
- Steps 01–08 must be working and tested before this cleanup step.
## Notes
- Run `cargo check` after each deletion batch to catch any remaining references.
- The `doctor` crate dependency stays — it's used by `commands/doctor.rs` which is not part of this migration.
- The `acp-client` crate was also used by `goose_serve.rs` for `acp_client::find_acp_agent_by_id("goose")` in binary resolution. Check if this import still exists. If so, you need an alternative way to find the goose binary. Options:
- Keep `acp-client` just for binary discovery (lightweight — only uses the `find_acp_agent_by_id` function)
- Inline the binary discovery logic (look for `goose` on PATH, check `GOOSE_BIN` env var)
- The `GOOSE_BIN` env var path is already handled. The `acp_client::find_acp_agent_by_id` fallback scans login shell PATH. You could replace it with a simple `which goose` equivalent.
If keeping `acp-client` just for binary discovery is acceptable, that's the simplest path. Otherwise, port the discovery logic.
@@ -0,0 +1,325 @@
# Step 10: Phase B — Migrate Config, Personas, Skills, Projects, Git, Doctor to `goose serve`
## Objective
This step is a roadmap for the long-term migration. Each subsystem currently handled by Rust Tauri commands will move behind `goose serve` ACP extension methods, callable from TypeScript via `client.extMethod("goose/<domain>/<action>", params)` or via generated `GooseExtClient` methods.
**This step requires backend changes to the goose crate** — adding new ACP extension methods to `goose serve`. It cannot be done purely in the Tauri app.
## Current State After Phase A (Steps 01–09)
After Phase A, the Rust Tauri backend still handles:
| Module | Rust File(s) | Lines | Native Dependency |
|--------|-------------|-------|-------------------|
| Config (config.yaml, secrets, keyring) | `services/goose_config.rs`, `services/provider_defs.rs` | ~590 | Keyring, file system |
| Credentials commands | `commands/credentials.rs` | ~50 | GooseConfig |
| Personas | `services/personas.rs`, `types/agents.rs`, `types/builtin_personas.rs` | ~920 | File system |
| Persona commands | `commands/agents.rs` | ~210 | PersonaStore |
| Skills | `commands/skills.rs` | ~320 | File system |
| Projects | `commands/projects.rs` | ~495 | File system |
| Git operations | `commands/git.rs`, `commands/git_changes.rs` | ~570 | Shell commands |
| Doctor | `commands/doctor.rs` | ~15 | `doctor` crate |
| Agent setup | `commands/agent_setup.rs` | ~310 | Shell commands, streaming output |
| Model setup | `commands/model_setup.rs` | ~220 | Shell commands, streaming output |
| System utilities | `commands/system.rs` | ~360 | File system, dialog |
| **Total** | | **~4,060** | |
## Migration Pattern
For each subsystem, the pattern is:
1. **Backend**: Add ACP extension methods to `goose serve` (in the `goose-acp` or `goose` crate)
2. **Schema**: Regenerate the ACP schema (`npm run build:schema` in `ui/acp/`)
3. **Client**: The `GooseExtClient` auto-generates typed methods from the schema
4. **Frontend**: Replace `invoke("rust_command")` calls with `client.goose.<method>()` calls
5. **Cleanup**: Delete the Rust Tauri command and service code
## Subsystem Migration Details
### B1: Config Management
**Priority: High** — Config is needed for provider setup, which is part of the core onboarding flow.
#### New `goose serve` Extension Methods
| Method | Request | Response |
|--------|---------|----------|
| `goose/config/get` | `{ key: string }` | `{ value: string \| null }` |
| `goose/config/set` | `{ key: string, value: string }` | `{}` |
| `goose/config/delete` | `{ key: string }` | `{ removed: boolean }` |
| `goose/secret/getMasked` | `{ key: string }` | `{ value: string \| null }` |
| `goose/secret/set` | `{ key: string, value: string }` | `{}` |
| `goose/secret/delete` | `{ key: string }` | `{ removed: boolean }` |
| `goose/provider/status` | `{ providerId: string }` | `{ providerId: string, isConfigured: boolean }` |
| `goose/provider/statusAll` | `{}` | `{ providers: [{ providerId: string, isConfigured: boolean }] }` |
| `goose/provider/fields` | `{ providerId: string }` | `{ fields: [{ key: string, value: string \| null, isSet: boolean, isSecret: boolean, required: boolean }] }` |
| `goose/provider/deleteConfig` | `{ providerId: string }` | `{}` |
#### Backend Implementation Notes
- The goose binary already has config management internally (`goose configure` command). The extension methods expose the same logic over ACP.
- Keyring access happens in the `goose serve` process (which runs natively), so there's no loss of capability.
- The `provider_defs.rs` static definitions should move to the goose crate (or already exist there).
#### Frontend Changes
- Replace `invoke("get_provider_config")` → `client.goose.gooseProviderFields({ providerId })`
- Replace `invoke("save_provider_field")` → `client.goose.gooseSecretSet({ key, value })` or `client.goose.gooseConfigSet({ key, value })`
- Replace `invoke("delete_provider_config")` → `client.goose.gooseProviderDeleteConfig({ providerId })`
- Replace `invoke("check_all_provider_status")` → `client.goose.gooseProviderStatusAll({})`
- Replace `invoke("restart_app")` → keep in Rust (native window management)
#### Files Deleted After B1
- `src-tauri/src/services/goose_config.rs`
- `src-tauri/src/services/provider_defs.rs`
- `src-tauri/src/commands/credentials.rs` (except `restart_app` which stays)
- Remove `keyring` dependency from `Cargo.toml` (all 3 platform variants)
- Remove `etcetera` dependency
---
### B2: Personas
**Priority: Medium** — Personas are used in the chat flow but not on the critical path.
#### New `goose serve` Extension Methods
| Method | Request | Response |
|--------|---------|----------|
| `goose/personas/list` | `{}` | `{ personas: Persona[] }` |
| `goose/personas/create` | `CreatePersonaRequest` | `{ persona: Persona }` |
| `goose/personas/update` | `{ id: string, ...UpdatePersonaRequest }` | `{ persona: Persona }` |
| `goose/personas/delete` | `{ id: string }` | `{}` |
| `goose/personas/refresh` | `{}` | `{ personas: Persona[] }` |
| `goose/personas/export` | `{ id: string }` | `{ json: string, suggestedFilename: string }` |
| `goose/personas/import` | `{ fileBytes: number[], fileName: string }` | `{ personas: Persona[] }` |
| `goose/personas/saveAvatar` | `{ personaId: string, bytes: number[], extension: string }` | `{ filename: string }` |
| `goose/personas/avatarsDir` | `{}` | `{ path: string }` |
#### Backend Implementation Notes
- Persona storage (`~/.goose/personas.json`, `~/.goose/agents/*.md`) is file-based. The goose binary can read/write these.
- Avatar handling (`~/.goose/avatars/`) is also file-based.
- Builtin personas are currently defined in `types/builtin_personas.rs` — these should move to the goose crate.
#### Files Deleted After B2
- `src-tauri/src/services/personas.rs`
- `src-tauri/src/types/agents.rs`
- `src-tauri/src/types/builtin_personas.rs`
- `src-tauri/src/types/messages.rs`
- `src-tauri/src/types/mod.rs`
- `src-tauri/src/commands/agents.rs`
---
### B3: Skills
**Priority: Low** — Skills are a secondary feature.
#### New `goose serve` Extension Methods
| Method | Request | Response |
|--------|---------|----------|
| `goose/skills/list` | `{}` | `{ skills: SkillInfo[] }` |
| `goose/skills/create` | `{ name, description, instructions }` | `{}` |
| `goose/skills/update` | `{ name, description, instructions }` | `{ skill: SkillInfo }` |
| `goose/skills/delete` | `{ name: string }` | `{}` |
| `goose/skills/export` | `{ name: string }` | `{ json: string, filename: string }` |
| `goose/skills/import` | `{ fileBytes: number[], fileName: string }` | `{ skills: SkillInfo[] }` |
#### Files Deleted After B3
- `src-tauri/src/commands/skills.rs`
---
### B4: Projects
**Priority: Low** — Projects are a secondary feature.
#### New `goose serve` Extension Methods
| Method | Request | Response |
|--------|---------|----------|
| `goose/projects/list` | `{}` | `{ projects: ProjectInfo[] }` |
| `goose/projects/create` | `{ name, description, prompt, icon, color, ... }` | `{ project: ProjectInfo }` |
| `goose/projects/update` | `{ id, name, description, prompt, icon, color, ... }` | `{ project: ProjectInfo }` |
| `goose/projects/delete` | `{ id: string }` | `{}` |
| `goose/projects/get` | `{ id: string }` | `{ project: ProjectInfo }` |
| `goose/projects/listArchived` | `{}` | `{ projects: ProjectInfo[] }` |
| `goose/projects/archive` | `{ id: string }` | `{}` |
| `goose/projects/restore` | `{ id: string }` | `{}` |
#### Files Deleted After B4
- `src-tauri/src/commands/projects.rs`
---
### B5: Git Operations
**Priority: Medium** — Git state is shown in the workspace widget and context panel.
#### New `goose serve` Extension Methods
| Method | Request | Response |
|--------|---------|----------|
| `goose/git/state` | `{ path: string }` | `GitState` |
| `goose/git/changedFiles` | `{ path: string }` | `{ files: ChangedFile[] }` |
| `goose/git/switchBranch` | `{ path, branch }` | `{}` |
| `goose/git/stash` | `{ path }` | `{}` |
| `goose/git/init` | `{ path }` | `{}` |
| `goose/git/fetch` | `{ path }` | `{}` |
| `goose/git/pull` | `{ path }` | `{}` |
| `goose/git/createBranch` | `{ path, name, baseBranch }` | `{}` |
| `goose/git/createWorktree` | `{ path, name, branch, createBranch, baseBranch? }` | `CreatedWorktree` |
#### Backend Implementation Notes
- Git operations run shell commands (`git status`, `git switch`, etc.). The goose binary can run these the same way.
- The `ignore` crate is used for `.gitignore`-aware file scanning in `list_files_for_mentions`. This could also move to goose serve.
#### Files Deleted After B5
- `src-tauri/src/commands/git.rs`
- `src-tauri/src/commands/git_changes.rs`
---
### B6: Doctor
**Priority: Low** — Doctor is a diagnostic tool, not on the critical path.
#### New `goose serve` Extension Methods
| Method | Request | Response |
|--------|---------|----------|
| `goose/doctor/run` | `{}` | `DoctorReport` |
| `goose/doctor/fix` | `{ checkId: string, fixType: string }` | `{}` |
#### Backend Implementation Notes
- The `doctor` crate already exists in the goose ecosystem. The extension methods just expose it over ACP.
#### Files Deleted After B6
- `src-tauri/src/commands/doctor.rs`
- Remove `doctor` dependency from `Cargo.toml`
---
### B7: Agent & Model Setup
**Priority: Medium** — Needed for onboarding third-party agents and OAuth flows.
This is the trickiest subsystem because it involves **interactive shell commands with streaming output**. The current Rust code spawns a child process and streams stdout/stderr lines as Tauri events (`agent-setup:output`, `model-setup:output`).
#### Options
**Option A: ACP extension methods with streaming notifications**
Add extension methods that return immediately but stream progress via `SessionNotification` events (or a new notification type). The frontend listens for these notifications the same way it listens for chat streaming.
**Option B: Keep in Rust as the last native commands**
These commands are inherently interactive (they open browsers for OAuth, wait for user input). They may be better suited to native handling. Keep them as the last remaining Tauri commands.
**Option C: Move the logic into `goose serve` as a long-running task**
The goose binary already handles `goose configure` (which is what `model_setup.rs` wraps). Expose a `goose/setup/authenticateProvider` extension method that runs the configure flow and streams output.
#### Recommendation
Start with **Option B** (keep in Rust) and migrate to Option A or C later. These commands are rarely called (only during onboarding) and the streaming output pattern would need a new ACP notification type.
---
### B8: System Utilities
**Priority: Low** — These are helper functions, not core features.
#### New `goose serve` Extension Methods
| Method | Request | Response |
|--------|---------|----------|
| `goose/system/homeDir` | `{}` | `{ path: string }` |
| `goose/system/pathExists` | `{ path: string }` | `{ exists: boolean }` |
| `goose/system/listDir` | `{ path: string }` | `{ entries: FileTreeEntry[] }` |
| `goose/system/listFilesForMentions` | `{ roots: string[], maxResults?: number }` | `{ files: string[] }` |
#### Special Case: `saveExportedSessionFile`
This command uses `tauri_plugin_dialog` to show a native save dialog. This is inherently a Tauri/native operation — it cannot move to `goose serve`. **Keep this in Rust.**
#### Files Deleted After B8
- `src-tauri/src/commands/system.rs` (except `save_exported_session_file` which stays)
- Remove `ignore` dependency from `Cargo.toml`
---
## End State After All Phase B Steps
**Rust Tauri backend:**
```
src-tauri/src/
lib.rs — ~40 lines: spawn goose serve, register ~3 commands
main.rs — 6 lines (unchanged)
commands/
mod.rs — 3 modules
acp.rs — get_goose_serve_url (~15 lines)
system.rs — save_exported_session_file (~40 lines)
agent_setup.rs — install/auth agents (~310 lines, if kept)
model_setup.rs — model provider auth (~220 lines, if kept)
services/
mod.rs — 1 module
acp/
mod.rs — 1 module
goose_serve.rs — GooseServeProcess (~150 lines)
```
**Total: ~780 lines** if keeping agent/model setup, or **~250 lines** if those also move.
**Cargo.toml dependencies (minimal):**
```toml
tauri = "2"
tauri-plugin-opener = "2"
tauri-plugin-dialog = ">=2,<2.7"
tauri-plugin-window-state = "2"
tauri-plugin-log = "2"
serde = "1"
serde_json = "1"
tokio = "1"
dirs = "6"
log = "0.4"
```
## Migration Priority Summary
| Step | Effort | Blocking On | Value | Recommended Order |
|------|--------|-------------|-------|-------------------|
| B1 (Config) | Medium | Backend ACP methods | High (removes keyring dep) | 1st |
| B5 (Git) | Medium | Backend ACP methods | Medium | 2nd |
| B2 (Personas) | Medium | Backend ACP methods | Medium | 3rd |
| B3 (Skills) | Small | Backend ACP methods | Small | 4th |
| B4 (Projects) | Small | Backend ACP methods | Small | 5th |
| B6 (Doctor) | Small | Backend ACP methods | Small | 6th |
| B8 (System utils) | Small | Backend ACP methods | Small | 7th |
| B7 (Agent/Model setup) | Large | Streaming notification design | Medium | Last (or keep in Rust) |
## How to Propose Backend Changes
For each subsystem, the process is:
1. **Design the ACP extension method schemas** in `crates/goose-acp/`
2. **Implement the handlers** in the goose serve server
3. **Regenerate the schema**: `cd ui/acp && npm run build:schema`
4. **Rebuild the TS client**: `cd ui/acp && npm run build`
5. **Update goose2**: use the new `client.goose.<method>()` calls
6. **Delete the Rust Tauri code**
Each subsystem can be migrated independently. The frontend can use a mix of `invoke()` (for not-yet-migrated subsystems) and `client.goose.*()` (for migrated ones) during the transition.