Skip to content

Share Maple Agent desktop and ACP through one local host - #1161

Merged
AnthonyRonning merged 8 commits into
masterfrom
codex-agent-host-p1-maple
Oct 16, 2026
Merged

AnthonyRonning merged 8 commits into
masterfrom
codex-agent-host-p1-maple

Conversation

@AnthonyRonning

@AnthonyRonning AnthonyRonning commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Desktop and maple-agent acp now attach to one on-demand local host. Starting ACP first creates the same owner that desktop later joins, so both interfaces can resume a task, observe its stream, answer questions and cancel its run without competing runtimes or store writers. The existing backend moves to backend/local.rs; the desktop facade becomes an IPC client. Pi is unchanged.

The host owns account state, task persistence, tools and children. OS-private sockets/named pipes, startup locks and a profile/version handshake define its boundary. Closing one client detaches it; the last intentional desktop Quit or ACP detach triggers graceful shutdown. An unexpected desktop connection loss allows 30 seconds for recovery; desktop reconnects for up to 15 seconds and reloads history, running tasks, queues and questions before accepting another send. Account validation can run alongside local operations; actual account switches remain exclusive. Sign-out revokes ACP connections and old account handles. Snapshot revisions reconcile live partials, and durable send receipts let desktop resolve a lost admission reply without replaying inference.

ACP remains a standards-based projection. It supports the shared Codex/Claude _session/steering extension, reasoning controls and negotiated form questions. Client MCP bindings are transient and disabled on detach. CUA, external agents and host filesystem tools use the shared execution owner. Optional ACP session/close is omitted because its cancellation semantics differ from pipe EOF/detach.

This is local Phase 1 only: no remote transport, pairing, background-service installation or Pi changes.

Validation

  • macOS ARM64, fresh managed Local environment with local OpenSecret and billing, Pro fixture and live Continuum inference. Hosted Dev flags fallback was present; this does not prove local flags entitlement behavior.

  • nix develop --no-update-lock-file -c just ci: passed. Includes 267 Maple runtime tests (2 ignored), 458 desktop tests, 67 headless app tests, the remaining workspace tests and all four lint configurations.

  • Native simultaneous ACP startup elected one host; incompatible profile/build rejected; last-client exit and explicit host stop exited cleanly.

  • Live desktop/ACP task sharing in both directions, mid-turn join, text/tool streaming, reasoning selection, steering, form questions, cancellation and originating-client detach passed.

  • Lost-reply send admission reconciled without a second run. Sign-out closed a retained native ACP process; old-account requests and silent account replacement were rejected; Free/Pro task isolation passed.

  • Native CUA window enumeration passed from an ACP-created task under the workspace's ad-hoc debug bundle. Desktop sent a Flash turn with bash, rendered its generated tool summary and the task reloaded over ACP.

  • Exercised the generic ACP provider source (b2ca7dcf, 2026-10-15): create, model selection, streamed tool use, close and resume/history passed. Also tested installed Paseo 0.11.1 with the native Maple desktop: a long first reply, a desktop continuation delivered live to Paseo, and another prompt on the same Paseo connection all passed. Manual testing confirmed simultaneous streaming and repeated continuation from both interfaces.

  • Regression coverage streams beyond the runtime broadcast capacity while titles change, then continues from desktop and ACP. Metadata updates drain independently of the whole-turn history gate.

  • Root nix flake check --no-update-lock-file passed. A native disposable-DMG check reproduced the old cleanup failure and verified bounded detach retries, including preserving the mounted fixture when it remained busy.

  • Added regressions for standalone external-agent updates during an ACP turn, incompatible-host stop/startup handling, shutdown ownership waiting, signed-out ACP diagnostics, nonblocking credential restoration, connection loss/overflow, queued commands crossing a disconnect, lost send receipts, account changes, snapshot/event reconciliation, explicit stop and multi-client shutdown. Frontend runtime teardown is safe when its last reference is released by an async callback.

  • Live Local Pro recovery smoke: an active Bash tool survived loss of its only client and a three-second gap; the same host recovered the active run and completed response, with exactly one user message. Explicit stop drained the isolated host.

  • Native macOS GUI opened/rendered successfully with the new cleanup path. The package probe now explicitly stops its own isolated host before unmounting; all 11 probe regressions passed.

Review boundaries

Windows named pipes and Linux IPC are implemented but were not executed natively here. External agents have automated fixture coverage; authenticated Codex/Claude runs were not repeated. This is a local debug build, not production packaging validation.

Paseo's generic provider does not yet call _session/steering and rejects a second active turn itself. Desktop-originated user and assistant messages stream to an already attached idle Paseo client. Stable ACP v1 does not provide a separate running/idle lifecycle for turns started elsewhere, so transcript delivery does not establish full desktop status parity. A disconnected/slow desktop automatically reconnects within the bounded recovery window; failed recovery asks the user to reopen. Recovery does not spawn a replacement host or replay commands. Remote keepalive remains outside this phase. Host crashes recover stored history without automatic inference replay or a promise that tools survive. Details are in apps/maple-agent/docs/local-host.md.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@AnthonyRonning
AnthonyRonning force-pushed the codex-agent-host-p1-maple branch from 6efde87 to 108e6ec Compare October 15, 2026 23:09
@AnthonyRonning

Copy link
Copy Markdown
Contributor Author

Review at e580912 against the code-docs #88 plan

Overall. The shape is right and it is the base Phase 2 should build on: one host per profile behind an OS file lock and a handshake, the desktop reduced to an IPC client, ACP as a projection that never owns the runtime (pipe EOF detaches, session/cancel stops), host-owned tools for every interface, send receipts and snapshot revisions as the resync and retry primitives remote will need, and a per-client server loop that accepts any byte stream. The backend move is a near-straight copy (about 230 lines actually change). Not ready to merge yet: one reproduced P1, five gaps that each end in a crash or a lost run, and a few plan items to record.

How it was checked. Read the #88 package and the whole diff; built this head and ran the ACP harness plus a new reproduction test; ran lifecycle probes of the debug binary on an isolated profile with no sign-in (startup election, profile mismatch, explicit stop, idle exit, shutdown window). CI is green on all three platforms. Not run: signed-in flows (the lag chain, permission attribution) and native Windows/Linux. Paths are under apps/maple-agent/, line numbers at e580912.

Fix before merge

  1. ACP connection ends after an external-agent row during its own turn (reproduced). observe() skips run events while this connection's prompt is active, but for AgentServiceEvent::TimelineItem it waits on the per-session gate. External agents emit their activity rows as exactly that event (emit_item), and prompt() holds the gate for the whole turn. The observer stops draining the 256-slot broadcast, is lagged by the time the turn ends, and start_observing treats lag as fatal: the connection closes while the host keeps running the turn. 144b01f fixed the same pattern for title updates; this PR makes it reachable by giving ACP tasks external agents. Reproduction on the stdio harness: faux provider with a 2 ms chunk delay and a 400-chunk reply, session/prompt, emit one TimelineItem for the session once the first model request is seen, then session/list after end_turn. The serving task has finished. The same scenario without the row, or with a 40-chunk reply, passes. Fix: never block the observer on the gate (route or drop items of a session whose prompt this connection owns) and treat lag as "resync" rather than "close".

  2. No recovery from a build or profile mismatch, and the desktop crashes instead of explaining. serve_client rejects a different build or profile before reading the connection mode and the client-side handshake does the same, so maple-agent host stop from the new build is refused (probe: "Incompatible Maple host or profile; explicitly quit the existing host first", exit 1). AgentBackend::new is still under .expect(), so the new desktop panics before a window opens. After an app update, an editor's old adapter keeps the old host alive and only killing the PID helps. Fix: honor Stop for any same-namespace client regardless of build (the OS user is already the trust boundary), show a real error or a "quit the other Maple host" choice in the desktop, and never expect() on the connection.

  3. A client arriving during host shutdown fails after the deadline (reproduced). On idle exit the host drops its listener first and then spends up to about 20 s stopping runs and external agents (RUN_SHUTDOWN_TIMEOUT, EXTERNAL_AGENTS_SHUTDOWN) while still holding host.lock. A client in that window spawns a replacement that exits on the lock, then polls for a socket nobody creates until its deadline. Reproduced by removing a live host's socket: the adapter failed with "Maple host did not start" after 15 s; on the desktop path this is item 2's panic. Triggers: quit and reopen during a run; Paseo's short history-fetch adapter followed by the real one. Fix: host serve waits for the lock until the startup deadline, or the old host keeps its listener until shutdown completes and answers "shutting down".

  4. maple-agent acp without a saved sign-in exits 0 silently (probe). serve_acp returns an error inside the host, logged at debug only, and the adapter's copy loop just sees EOF. Master printed "No saved Maple sign-in. Open the desktop app and sign in first." and exited 1; Paseo now shows an agent that died for no reason. Minimal fix: check Welcome.user_id in host::acp and print the message; better, answer initialize and return ACP's auth_required on session/new.

  5. The account gate blocks unrelated work. restore_auth takes the write side and holds it through credential validation bounded at 30 s (CREDENTIAL_VALIDATION_TIMEOUT), and the desktop issues it before local_bootstrap, so the local task list waits behind it: offline or behind a captive portal that is up to 30 s of blank window, contrary to the comment at desktop.rs:507. An ACP attach takes the write side too (serve_acp) and, because the lock is fair, queues every later desktop call behind any long read call already running (compaction, side question, transcription). Fix: check the account when an operation is admitted and rely on verify_generation, instead of holding a lock across network calls.

  6. A lagging sole desktop loses the running task. The client reader disconnects when its 256-event buffer is full, and the host drops a client on broadcast lag or a 15 s write timeout. If that was the last client, serve exits after the 2 s grace and shutdown_host cancels runs, and the desktop has no reconnect. A UI stall of a few seconds at streaming rates becomes a cancelled turn. Phase 1 can keep "reload after lag", but the host should not cancel work two seconds after an involuntary disconnect; a longer grace for that case, or desktop reconnect plus snapshot reload, also softens items 2 and 3.

Before Phase 2

  • Full-row resend per chunk. RunEvents::publish turns every text or thinking delta into the full accumulated row, cloned twice in-process and JSON-encoded again per client, so bytes grow with the square of the message length (a 100 KB thinking trace is on the order of a gigabyte per client at per-token chunks). Survivable over a local socket, not over Iroh or a relay. Send deltas tagged with the row revision, full rows only in snapshots and settled updates, and coalesce for slow clients.
  • Resume. Reconnect with the last revision seen, then a short replay or a fresh snapshot. Add a host-instance id to revisions: NEXT_TIMELINE_REVISION restarts with every host process.
  • Explicit wire contract. Methods are Rust function names with positional arrays and string errors (operations!); compatibility is an exact build-string match. Fine for one binary; two desktops will update at different times. Define named request and response types, error codes and a negotiated protocol version, and keep the exact-build check only for storage ownership. The plan asked not to serialize internal types wholesale; this is the moment to stop.
  • Scopes and identity. Every connection can call everything (logout, account deletion, API keys, saving MCP servers) with a client-supplied user_id. Fine within one OS user; a paired device needs permission scopes and an identity taken from the connection.
  • Starter environment and permissions. spawn_host sets the cwd, but the host inherits the rest of its starter's environment (tokens, proxies, DISPLAY). On macOS it inherits the starter's permission identity: CUA is no longer desktop-only and request_permissions runs on the desktop click so the grant is attributed to Maple, but a host started by Paseo or a terminal checks Accessibility and Screen Recording against that app. The CUA validation here ran in a desktop-started host, so this path is untested; validate on a signed build with Paseo started first, and consider launching the host as its own responsible process. Browser opens for OAuth and billing run in the host (backend/local.rs:1088,1126,2374); the client should open the URL.

Smaller items

  • Lock files live in /tmp/maple-agent-<uid>: another local user can pre-create that directory and block Maple, and a private /tmp gives a second host the same data. Keep the ownership lock next to the data; only the socket needs the short path.
  • session/cancel stops whatever run is on the task. The register made cancel a shared-task stop, but a cancel racing the end of this connection's turn stops the desktop's next queued run. Cancel the connection's own run id when it has one, shared stop otherwise.
  • Questions: a connected desktop counts as a capable responder, so an ACP turn without form support waits on a question only the desktop shows. That is the plan's behavior; send the ACP client a text notice that the turn is waiting in Maple desktop.
  • maple-agent login says a saved sign-in "will be replaced" (app/src/main.rs:332-336) but the host now refuses it (require_signed_out).
  • Host mode ignores RUST_LOG (init_logging(FileOnly) parses only the default filter), so the process that now does all the work cannot be made verbose; the README still says RUST_LOG=debug.
  • Windows: CREATE_BREAKAWAY_FROM_JOB (host.rs:307) fails outright when the parent's job forbids it; retry without the flag.
  • Efficiency: three auth.json reads per host request (saved_user_id before and after, plus authorize_account); refresh_config_options loads the full timeline to read message_count and model; owned_runs is never pruned; AgentRunSurface is dead.
  • Lifecycle tests: spawn, handshake, idle grace, shutdown, mismatch and stop have no automated coverage. With an injectable spawner, items 2, 3 and 6 are testable in-process.

Plan and docs (code-docs #88)

  • Record the engineering defaults this PR chose: the 2 s idle grace, the profile composition, EOF = detach with session/close omitted, immediate disabling of client MCP bindings on detach (the plan said freeze them for the turn), login-shell PATH for ACP tasks, receipts, and the _session/steering adoption. The method, _meta.steering.supported, injected and startedNewTurn match codex-acp 9dac81d and claude-agent-acp 0c8de13; idleBehavior=promptRequired exists only in claude-agent-acp's example client, so docs/local-host.md should not call it shared.
  • Outstanding Phase 1 acceptance: signed-macOS permission validation with the ACP-first start order, native Windows and Linux IPC smokes, and the contract tests above. Model selector #88 still reads "implementation has not begun".

Review by Claude Code, folding in a second reviewer's pass; posted from Anthony's account.

🤖 Generated with Claude Code

@AnthonyRonning
AnthonyRonning marked this pull request as ready for review October 16, 2026 18:33
@AnthonyRonning
AnthonyRonning merged commit 2261150 into master Oct 16, 2026
37 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