Skip to content

Refactor Maple Agent runtime for thin Desktop and ACP adapters - #732

Merged
AnthonyRonning merged 6 commits into
masterfrom
codex-maple-agent-service-refactor-maple
Aug 4, 2026
Merged

Refactor Maple Agent runtime for thin Desktop and ACP adapters#732
AnthonyRonning merged 6 commits into
masterfrom
codex-maple-agent-service-refactor-maple

Conversation

@AnthonyRonning

Copy link
Copy Markdown
Contributor

Status

This is a draft architecture and implementation review.

This branch supersedes the implementation and architecture proposed in #714, while leaving that PR open as the original exploration record. It preserves the successful proof that Buzz can control the real Maple Agent without forking Buzz or Goose, but restructures the work around a maintainable Maple-owned runtime service.

The intended merge boundary is:

  • the shared Maple Agent runtime refactor should be production-quality and useful to primary Desktop Agent Mode;
  • normal Maple Agent behavior should remain unchanged;
  • ACP remains an experimental, desktop-only, default-off edge adapter; and
  • no remote ACP, HTTP control plane, mobile control, or complete Goose ACP parity is proposed here.

Summary

This PR:

  • makes the existing embedded-Goose runtime explicitly accessible through a transport-neutral MapleAgentService;
  • keeps Tauri as a thin projection for Maple Desktop;
  • keeps ACP as a separate thin projection for external harnesses;
  • ensures both surfaces use the same account-scoped Maple provider, tasks, tools, policy, history, and lifecycle;
  • routes unresolved ACP permissions to the ACP caller that started the run;
  • isolates live Desktop and ACP status, cancellation, timelines, and permissions;
  • adds a composite host lifecycle for stop, restart, clear, update, logout, and exit;
  • supports a packaged maple acp stdio connector backed by an owner-only local Unix socket;
  • keeps Buzz credentials in bounded, revocable, connection/session-scoped tool context rather than process-global state or persisted harness configuration;
  • hides the Agent connections UI behind a local-only feature gate; and
  • does not fork Buzz or Goose.

The important result is not merely that Maple speaks ACP. It is that there remains exactly one Maple-controlled Goose runtime.

Architecture

flowchart LR
    UI["Maple Agent UI"] --> FE["AgentRuntimeService"]
    FE --> Tauri["Thin Tauri commands and event projection"]

    Client["Buzz or another ACP client"] --> Connector["maple acp stdio connector"]
    Connector --> Socket["Owner-only local Unix socket"]
    Socket --> ACP["ACP transport and protocol adapter"]

    Tauri --> Core["MapleAgentService typed runtime facade"]
    ACP --> Core

    Host["AgentHostLifecycle"] -. "serializes stop, restart, clear, update, and exit" .-> ACP
    Host -. "composite lifecycle" .-> Core

    Core --> Goose["Existing embedded Goose runtime"]
    Goose --> Provider["MapleProvider and authenticated MapleApiSession"]
    Goose --> Tools["Maple developer, web, and Skills clients"]
Loading

There is one account-scoped runtime. Desktop and ACP independently project onto it; neither adapter calls through the other.

The core service owns Maple semantics:

  • account-bound, generation-checked runtime handles;
  • task creation, loading, deletion, and history;
  • model locking and restoration;
  • run admission, cancellation, and retained terminal state;
  • isolated bounded event streams;
  • typed permission requests and exact-run responders;
  • transient tool-context leases and revocation;
  • runtime/session lifecycle fencing; and
  • Maple's existing provider, developer tools, web tools, and trusted Skills behavior.

The adapters own surface-specific concerns:

Layer Responsibilities
Tauri adapter Existing Desktop command arguments, agent-event envelopes, and UI projection
ACP adapter ACP dispatch, notifications, permissions, stop reasons, connection/session leases, bounded wire flow, and Buzz compatibility
Host lifecycle Atomic operations that must span both the runtime and ACP listener
maple acp connector Bridge harness-owned stdio and environment into the already-running Desktop process

The core imports no Tauri or ACP protocol types. “Thin adapter” refers to this ownership boundary, not necessarily line count: a safe ACP edge still needs substantial transport, correlation, backpressure, cleanup, and IPC logic.

Why this refactor is useful beyond ACP

This is directionally the architecture Maple Agent should have even if ACP remains hidden.

Primary Agent Mode stays direct:

Maple UI -> Tauri adapter -> MapleAgentService -> embedded Goose -> MapleProvider

External harnesses remain optional:

External harness -> ACP adapter -> MapleAgentService -> embedded Goose -> MapleProvider

The service is a Maple-owned superset. Desktop and ACP do not need identical command sets, event types, or capabilities. Each adapter exposes the subset appropriate for its caller without forcing Maple's domain model to conform to ACP.

This PR intentionally does not extract the core into a standalone crate or decompose every large Agent module. It remains composed inside the Tauri binary. A future headless host could justify further extraction, but this pass lets primary Maple Agent features continue driving the internal design.

Desktop compatibility

The Desktop boundary remains intentionally stable:

  • existing Tauri command names and arguments remain;
  • existing agent-event envelopes remain;
  • normal Agent Mode interaction and rendering remain; and
  • no frontend Agent Mode architecture was replaced with ACP.

Stop and restart now return a small lifecycle outcome:

{
  status: AgentRuntimeStatus;
  acpShutdownError: string | null;
}

This is intentional. If the core runtime successfully changes state but ACP cleanup fails, Desktop must apply the authoritative runtime status instead of remaining visually stale. The frontend unwraps this result, refreshes sessions, and then shows the cleanup warning.

Surface ownership and isolation

A late review pass found several places where a shared runtime could accidentally expose one surface's live state to another. This branch now makes ownership explicit:

  • Desktop runtime status excludes ACP-owned runs.
  • Desktop cancellation can act only on Desktop-owned runs.
  • ACP retains an opaque cancellation capability bound to the exact account, task, run, and calling surface.
  • Wrong-session or cross-surface cancellation is rejected before any permission, timeline, or tool-context side effect.
  • Live timeline state is tagged with its owning surface.
  • ACP events cannot overlay, mutate, or remove Desktop live state.
  • Persisted history remains visible later, but live control remains isolated.
  • Persisted tool and elicitation requests settle from canonical responses even if no stop notice was recorded.
  • When Desktop reconstructs a task, only a genuinely Desktop-owned pending request remains actionable.
  • An ACP-owned request is shown as controlled_externally; an orphaned pending request becomes cancelled.

The intended distinction is:

Desktop may inspect an ACP-created task later, but cannot approve, cancel, or otherwise control a live leased ACP run.

Focused regression tests cover status filtering, cancellation scope, timeline isolation, terminal cleanup, and persisted permission reconciliation.

Caller-owned ACP approvals

The surface that starts a run owns its unresolved interactive permission decisions:

sequenceDiagram
    participant Client as ACP caller
    participant Adapter as Maple ACP adapter
    participant Core as MapleAgentService
    participant Goose as Embedded Goose

    Client->>Adapter: session/prompt
    Adapter->>Core: send message with connection-scoped context
    Core->>Goose: run with Maple provider, tools, and policy
    Goose-->>Core: action requires permission
    Core->>Core: apply Maple automatic classifier

    alt Covered by Maple policy
        Core->>Goose: automatic decision
    else Unresolved ACP-owned request
        Core-->>Adapter: typed run-scoped permission request
        Adapter->>Client: request_permission
        Note over Adapter,Client: allow_once or reject_once
        Client-->>Adapter: caller decision
        Adapter->>Core: opaque exact-run response
        Core->>Goose: permission confirmation
    end

    Goose-->>Core: ordered events and terminal state
    Core-->>Adapter: bounded run stream
    Adapter-->>Client: session/update and prompt result
Loading

This branch deliberately does not add a “wait for approval inside Maple Desktop” broker for ACP runs.

Behavior is fail-closed:

  • cancellation, disconnect, transport failure, unknown options, duplicate responses, and reused request IDs do not grant access;
  • an outstanding ACP v1 permission request cannot be cancelled on the wire, so Maple cancels Goose immediately but retains the response future and its outbound-budget reservation until reply or disconnect;
  • the caller sees only allow_once and reject_once; and
  • Maple Desktop receives no actionable approval card for the ACP-owned run.

The old exploratory allow_all setting is migrated to the caller-mediated read_only/smart_approve policy. read_only is a retained serialized name, not a literal filesystem sandbox.

Buzz's current behavior is to select allow_once automatically. That is Buzz's product decision, not a second Maple approval mode.

Maple does not yet have a separate non-overridable “dangerous deny” classifier. If one is introduced later, it should run as Maple policy before any ACP permission request, not become a competing interactive broker.

Composite host lifecycle

ACP and the core runtime have separate internal state, but process-level lifecycle operations must cover both:

sequenceDiagram
    participant UI as Desktop frontend
    participant Fence as Account operation fence
    participant Host as Native host lifecycle
    participant ACP as ACP listener
    participant Core as Maple Agent runtime

    UI->>Fence: blockAndDrain(user)
    UI->>Host: stop or restart
    Host->>ACP: attempt shutdown
    ACP-->>Host: success or cleanup warning
    Host->>Core: always stop or restart
    Core-->>Host: authoritative runtime status
    Host-->>UI: status plus optional acpShutdownError
    UI->>UI: apply status and refresh sessions

    alt ACP cleanup warning
        UI->>UI: surface warning
        Note over UI: security-sensitive credential cleanup remains blocked
    end
Loading

The host lifecycle now:

  • serializes ACP and runtime stop/restart so a new connection cannot race between phases;
  • always attempts the core operation even if ACP cleanup fails;
  • returns successful runtime state together with a nonfatal ACP cleanup warning;
  • preserves strict failure behavior when the runtime itself fails;
  • keeps logout and credential cleanup fail-closed;
  • requires ACP cleanup before local Agent data/history deletion; and
  • treats update restart and application exit as strict shutdowns across both surfaces.

The frontend performs one native composite stop. The manual agent_acp_stop command remains reserved for the settings page because that action also updates saved configuration.

Why Goose ACP is not instantiated directly

The Goose team's suggested shape—add an ACP crate, instantiate a server, and plug in the existing GDK loop—is directionally exactly what Maple wants.

At Maple's pinned Goose revision, c3111c71cd682ed1d115741677f0ca9946c51499, the public Rust API is not yet that shape.

About the goose-acp crate

At this revision:

  • there is no reusable ACP runtime package named goose-acp;
  • goose-acp-macros exists, but it is a proc-macro crate for custom JSON-RPC dispatch and schema generation;
  • the ACP server implementation lives in the main goose::acp module; and
  • the reusable serve<R, W> transport is concrete over Arc<GooseAcpAgent>, not a host-supplied backend or loop trait.

GooseAcpAgent::new(GooseAcpAgentOptions) accepts a provider factory, builtins, paths, platform/source roots, naming policy, and scheduler. It does not accept an existing:

  • prompt loop;
  • AgentManager;
  • SessionManager;
  • PermissionManager;
  • task/session service;
  • permission broker;
  • tool surface; or
  • event stream.

Its constructor creates fresh session and permission managers, a fresh provider inventory, an AgentConfig using Config::global(), and a fresh AgentManager.

Consequently:

  • starting goose acp would expose independently configured Goose;
  • turning on goose serve after Maple initializes would still create a second runtime;
  • constructing GooseAcpAgent with a Maple provider factory would solve provider creation only;
  • sharing Maple's data directory would not share active-run claims, account generations, cancellation, permissions, tool-context leases, or event ownership; and
  • two independently cached runtimes could load and mutate the same persisted task without one lifecycle owner.

Maple's semantic boundary is not a bare Goose Agent. It includes:

  • the authenticated, account-scoped MapleApiSession and MapleProvider;
  • task persistence and model restoration/locking;
  • Maple developer and web tools;
  • trust-filtered project Skills;
  • transient external tool-context leases;
  • lifecycle and account generations;
  • Maple's automatic permission classifiers;
  • surface-scoped approval routing; and
  • Desktop timeline behavior.

Goose ACP also owns permission requests and confirmations directly, advertises persistent choices such as allow/reject always, can substitute ACP-client filesystem/terminal behavior, and accepts generic client MCP definitions. Bypassing Maple's wrapper would therefore change both its security policy and its product behavior.

Goose's mature history replay, response builders, tool converters, usage mapping, permission mapping, and handlers are useful, but at this revision they are private or pub(crate) and tied to concrete GooseAcpAgent state.

Upstream seams that would help

The ideal upstream improvement is to separate ACP protocol projection from runtime ownership. Useful options include:

  1. Construct ACP around existing manager handles.
  2. Provide a host-supplied backend or prompt-loop trait.
  3. Use an injectable provider resolver consistently across create, load, restore, and reconfigure.
  4. Add host admission, activation, prompt preparation, and cleanup hooks.
  5. Accept an invocation-scoped permission responder.
  6. Preserve host-installed developer/tool clients.
  7. Support transient per-session context with explicit cleanup.
  8. Extract Goose's AgentEvent -> session/update projector.
  9. Remove global configuration and path assumptions from the embedded path.

A GooseAcpAgent::from_components constructor plus pluggable prompt runner, permission broker, tool setup, and projector would also address most of the gap.

Even with those seams, Maple would retain the small local connector: Buzz owns the spawned stdio process and its environment, while Maple authentication lives in the already-running Desktop process.

Until then, the narrow adapter over agent-client-protocol 1.0.1 is the smallest no-fork path that exposes Maple rather than a parallel Goose runtime.

Current ACP surface

The detailed capability-by-capability analysis is in docs/agent-mode-acp.md. The condensed state is:

Capability Status Remaining work or limiting layer
initialize Yes Implemented with intentionally narrow advertised capabilities
session/new Yes Creates a real Maple task from an absolute cwd
Text session/prompt Yes One active prompt per session
Text/thought updates Partial Low–Medium projection work for richer updates
session/cancel Yes Exact run-scoped cancellation
Permission requests Yes Caller-owned, one-shot allow/reject
Basic tool lifecycle Partial Low–Medium projection and correlation work
session/list No Low code plus task-enumeration policy
session/close No Low–Medium race-safe ownership cleanup
Additional workspace roots No Medium Maple workspace/trust-model work
session/resume No Medium leasing and transient-context restoration
session/fork No Medium wrapper/policy/rollback work; unstable protocol commitment
Session mode No Medium mapping plus unattended-approval policy
Usage/context updates No Medium protocol-neutral usage and billing semantics
Prompt images No Medium structured prompt and model-capability work
session/load No Medium–High faithful ordered history projection
Model/config selection No Medium–High Maple catalog and model-lock semantics
Rich tools/resources/locations No Medium–High event-model/projector work
Terminal and diff parity No Medium–High architectural choice: Maple executes local tools rather than delegating them through ACP client RPCs
Embedded binary/resource links No Medium–High ingestion, provenance, root, and security policy
Arbitrary ACP-provided MCP No High code-execution, secret, lifecycle, and packaging boundary
Prompt audio No High; pinned Goose has no native audio message variant
Goose/Buzz steering extension No Medium code, high coupling; intentionally omitted because Buzz has cancel-and-merge fallback

No means “not implemented by this preview,” not “fundamentally impossible.”

Two ACP v1 baseline gaps are important:

  • generic ResourceLink prompt blocks are not accepted; and
  • arbitrary stdio MCP definitions are not supported.

The preview should therefore be described as parity for the tested Buzz task path, not complete ACP v1 conformance or Goose ACP parity.

None of the missing rows blocks the previously tested Buzz flow. Buzz does not require list/load/resume/fork/close for that path, tolerates Maple's default model/config, and falls back from its unstable native steering extension.

Buzz compatibility and credentials

The wire surface is standard ACP v1, but the first consumer requires explicit compatibility behavior:

  • a private bridge hello for connector-owned environment transfer;
  • a five-variable BUZZ_* allowlist plus PATH;
  • recognition of one narrowly shaped absolute buzz-dev-mcp definition;
  • adaptation into Maple's existing developer shell rather than launching arbitrary client MCP;
  • a Buzz ACP task title; and
  • custom-harness configuration and parallelism guidance.

If this integration is maintained, those constants and transformations should move behind a dedicated Buzz compatibility module.

External tool context is installed as an exclusive, revocable per-session lease. It is checked by account, session, and installation identity and never mutates Maple's process-global environment. Revocation is linearized with process launch so no already-copied context can start another command after the revoke barrier returns.

This improves containment, but it is not a sandbox:

  • the socket trusts processes running as the same OS user;
  • allowed project roots gate session admission, not every later tool path;
  • trusted agent commands can inspect or transmit Buzz credentials;
  • Unix process-group cleanup cannot prevent deliberate detachment; and
  • a hard Maple crash can bypass in-process cleanup.

Flatpak credential-bearing execution fails closed. Windows local IPC is unsupported.

Feature gate and configuration

The Agent connections surface is:

  • hidden and default-off;
  • enabled only by the local VITE_FORCE_FEATURE_FLAGS=agent_connections override;
  • unavailable to remote feature flags;
  • limited to macOS/Linux Tauri Desktop; and
  • unavailable on web, mobile, and Windows.

Activation is manual after every Maple launch. A saved enabled value does not auto-start the listener.

The page supports:

  • manual start/stop;
  • connection, session, and active-run status;
  • packaged executable path and acp argument;
  • Buzz harness JSON;
  • protocol, endpoint, and credential diagnostics; and
  • allowed-root and parallelism guidance.

Policy changes require Stop → Save → Start to avoid an admission race. Maple currently defaults to one ACP connection, matching the validated Buzz setup.

Historical Buzz end-to-end evidence

The original macOS exploration used:

  • Goose c3111c71cd682ed1d115741677f0ca9946c51499;
  • Buzz 3a4bf513df0e0c258587bfcbed9463d63723b56b;
  • a packaged arm64 macOS development app;
  • ACP v1;
  • Buzz owner-only channel admission;
  • Buzz parallelism 1; and
  • Maple's former unattended allow_all policy.

Two Buzz GUI tasks completed:

  1. A deterministic mention produced exactly MAPLE-GUI-OK.
  2. Maple read the checkout's real README.md using its local tools and posted a substantive explanation of the project.

This proves that the connector, local IPC, Maple runtime, local tools, ACP stream, and signed Buzz publication path can work together.

It does not prove the final caller-mediated permission path end to end. The old allow_all path has been removed and migrated. Current caller ownership is covered by implementation and focused tests, but this PR should not claim a fresh caller-owned Buzz GUI result unless one is explicitly rerun.

The README task took roughly two to three minutes. That has not been profiled and should not be attributed to ACP.

Validation

Automated and local validation on the rebased branch:

  • cargo check --tests passed.
  • Focused Rust cancellation, permission, timeline, surface-ownership, host-lifecycle, and ACP tests passed.
  • Full Rust cargo test --all-targets passed in the final pre-commit run: 264 passed, 0 failed, 1 model-backed OCR test ignored.
  • One pre-existing timing-sensitive developer-tools shell test flaked on an earlier first attempt, then passed in isolation and on both subsequent full runs.
  • Frontend tests: 504 passed, 0 failed, 1,731 assertions.
  • Production frontend build passed.
  • ESLint reported 0 errors and the same 13 pre-existing warnings.
  • cargo fmt --check, strict cargo clippy -- -D warnings, Prettier, and git diff --check passed. Patched tao emits its existing dependency warnings.
  • Managed local OpenSecret/Billing smoke passed after reboot (OpenSecret on 127.0.0.1:31061, Billing on 127.0.0.1:36201).
  • A workspace-specific debug Maple.app was built, ad-hoc signed, verified under the exact workspace bundle ID, and confirmed to contain the local OpenSecret endpoint. The expected updater-signing step reported the intentionally absent private key only after producing the .app, .dmg, and updater archive.
  • The exact packaged app loaded the existing Agent Mode UI and completed a real local GUI turn with the expected MAPLE-REVIEW-SMOKE-OK response.
  • A fresh review of the final diff found no confirmed medium- or high-priority issue.

Current test coverage includes:

  • Desktop status excluding caller-owned runs;
  • cross-surface and wrong-session cancellation rejection;
  • live timeline isolation and owner-only cleanup;
  • exact-surface permission updates;
  • persisted tool/elicitation settlement;
  • Desktop permission reconciliation;
  • caller permission option/outcome mapping;
  • outbound permission backpressure;
  • lifecycle result composition; and
  • frontend composite-stop behavior.

Still absent:

  • a checked-in complete ACP wire fixture;
  • socket lifecycle/reconnect coverage;
  • a Buzz GUI automation fixture;
  • a fresh caller-mediated Buzz E2E;
  • component-level restart-warning coverage; and
  • a real host integration test that injects simultaneous ACP and runtime shutdown failures.

Tradeoffs

Benefits

  • One real Maple runtime rather than a parallel Goose process.
  • No Buzz fork.
  • No Goose fork.
  • Primary Desktop Agent Mode stays direct.
  • Maple provider, account, task, tool, and policy semantics remain authoritative.
  • Protocol types do not leak into Maple's domain model.
  • Surface ownership and lifecycle are explicit and tested.
  • The service is useful for future non-UI hosts without prematurely designing remote control.

Costs

  • Maple owns a partial ACP adapter that overlaps upstream Goose behavior.
  • Safe ACP transport and lifecycle code is not small.
  • Buzz-specific compatibility remains mixed into the adapter.
  • The shared Agent core remains large.
  • Coarse lifecycle locks trade some control-plane concurrency for predictable race behavior.
  • The protocol surface is intentionally incomplete.
  • Local same-user IPC is a trust boundary, not application authentication.
  • There is no checked-in full wire or Buzz fixture yet.

Maintenance recommendation

Keep Maple Agent's primary path direct and continue building Maple features against MapleAgentService.

Keep ACP default-off at the edge. Add protocol capabilities only for demonstrated consumers rather than chasing speculative Goose parity.

Maintaining this bounded adapter is reasonable if external harness interoperability remains strategically useful. Maple should not:

  • restructure primary Agent Mode around ACP;
  • design remote/mobile control in this PR;
  • fork Goose solely for this preview; or
  • copy Goose's complete private ACP projection surface.

The best long-term reduction in Maple-owned ACP code is an upstream Goose embedding seam for runtime injection, permission routing, transient context, and event projection.

If the adapter remains:

  1. move Buzz compatibility into its own module;
  2. add a protocol/socket fixture before broadening support;
  3. keep the Desktop and calling-surface ownership tests as invariants;
  4. generalize the current Desktop/CallingSurface identity only when a real third surface requires it; and
  5. let primary Maple Agent requirements—not ACP completeness—drive further core refactoring.

Suggested review order

  1. frontend/src-tauri/src/agent.rs — Maple service, runtime handles, routing, cancellation, permissions, timelines.
  2. frontend/src-tauri/src/agent_tauri.rs — thin Desktop projection and preserved event contract.
  3. frontend/src-tauri/src/agent_host.rs — composite lifecycle behavior.
  4. frontend/src-tauri/src/agent/tool_context.rs and developer-tool changes — bounded transient context and process cleanup.
  5. frontend/src-tauri/src/agent_acp.rs — protocol, IPC, backpressure, caller permissions, and Buzz adaptation.
  6. Frontend runtime/settings services — feature gate, manual lifecycle, and structured cleanup warnings.
  7. docs/agent-mode-acp.md — detailed support matrix, trust model, and maintenance guidance.

Questions for reviewers

  1. Is MapleAgentService the right durable boundary for Maple-controlled Goose?
  2. Is caller-owned permission handling the correct ACP v1 policy?
  3. Are the Desktop/CallingSurface ownership rules sufficiently explicit?
  4. Is the composite lifecycle result the right way to represent partial ACP cleanup?
  5. Should the default-off ACP adapter remain in-tree while upstream Goose seams are pursued?
  6. Which missing ACP capability is justified by a real consumer next?
  7. Is the same-user local socket trust model acceptable for an experimental preview?
  8. Which Goose injection/projector seam would provide the most upstream leverage first?

@AnthonyRonning
AnthonyRonning force-pushed the codex-maple-agent-service-refactor-maple branch from 285d9f6 to 75a72f0 Compare August 4, 2026 20:46
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying maple with  Cloudflare Pages  Cloudflare Pages

Latest commit: 75a72f0
Status: ✅  Deploy successful!
Preview URL: https://70d45aab.maple-ca8.pages.dev
Branch Preview URL: https://codex-maple-agent-service-re.maple-ca8.pages.dev

View logs

@AnthonyRonning
AnthonyRonning marked this pull request as ready for review August 4, 2026 21:28
@AnthonyRonning
AnthonyRonning merged commit d4441bc into master Aug 4, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant