Skip to content

Import Maple Agent GPUI with shared dependencies and isolated CI - #885

Merged
AnthonyRonning merged 342 commits into
masterfrom
codex-mono-agent-maple
Sep 8, 2026
Merged

AnthonyRonning merged 342 commits into
masterfrom
codex-mono-agent-maple

Conversation

@AnthonyRonning

Copy link
Copy Markdown
Contributor

Bring the GPUI desktop-v2 prototype into apps/maple-agent so Agent work can use Maple's shared SDK and proxy without coordinating a forked SDK release. Research remains the shipped React/Tauri application with unchanged source, identity, update URLs and production deployment configuration.

The first commit preserves all 338 reachable upstream commits from benthecarman/maple-gpui master 6bedcf938b41629fca375ec96caaf5b8ddccaf67. Its imported subtree exactly equals source tree feaa3bc67584d881aa5c85d742302afc52a620b4; subsequent commits adapt the integration. Use a normal merge, not squash/rebase, to retain that ancestry. Provenance and the separate disposition of open upstream work are in docs/maple-agent-import.md.

  • Consume the existing local opensecret SDK and maple-proxy, remove the fork patch, and adapt catalog vision metadata with fallback tests. Package names and registry publishing are unchanged.
  • Add root Agent commands, component guidance, and Linux/macOS/Windows CI. Shared Rust runtime inputs select both apps; Agent-only source stays out of Research packaging. Every Agent platform verifies one canonical local SDK/proxy. PR jobs use pinned actions, read-only tokens, nonpersistent checkout credentials, no signing/deployment secrets, and caches saved only on trusted master pushes.
  • Replace global latest-release lookup with bounded, complete discovery of stable maple-agent-vX.Y.Z tags. Ignore Research tags and unsafe release URLs. The app still only displays a link. Research release jobs and downstream publishers skip Agent releases; no Agent publisher is activated. Future Agent releases must use make_latest: false.
  • Preserve managed development state for both shell and GUI launches. Managed debug bundles carry only public service configuration and both workspace XDG roots, disable update checks, and cannot inherit a saved proxy API key. The companion workspace manager PR #22 provides bin/maple-agent and distinct state alongside legacy GPUI checkouts.
  • Correct inherited Nix dependency hashes against exact commits. Pure packages are explicitly Linux-only: the pinned pure Darwin Swift/SDK combination cannot build CUA's required bridges. macOS uses the validated Nix development shell plus full Xcode; no feature is stubbed to make a package pass.

Validation:

  • Component just ci: formatting, four Clippy configurations, workspace build/tests and headless tests passed: 755 passed, three existing ignored. Includes update discovery and catalog-adapter regressions.
  • Normal debug build, strict ad-hoc bundle verification and bundled --version passed. The exact native bundle rendered an empty login screen; process open files proved the task's isolated state root. The test app was stopped. Authenticated chat, audio and embedded CUA behavior were not exercised.
  • Root nix flake check --no-update-lock-file passed, including Agent workflow/dependency/state-isolation checks, selectors, actionlint, Pages and release gates. Component all-system Nix evaluation passed; Linux pure packaging is not claimed built on this macOS host.
  • Normal commit hooks passed Research frontend build and all 818 tests. The Research, SDK, proxy, updates-service and repository-identity source trees are unchanged from the layout merge.
  • Independent dependency/update, CI security and manager reviews found no blocking issue. Hosted Agent platform checks are the remaining PR CI evidence.

The native link retains an upstream duplicate Swift type-symbol warning; source and executable inspection found identical bridge definitions and one linked copy per symbol, and packaged startup passed. It was not suppressed.

SDK renaming/official publishing, upstream feature-branch replay, official Agent distribution, backend import and PCR migration are separate follow-ups. No release or registry publication is created here.

benthecarman and others added 30 commits August 29, 2026 15:47
The screenshot, display, and upload instructions only applied to the
original developer's workstation. Keep the build, log, and performance
guidance that applies to any machine.
Start the version history fresh for the handoff.
Startup validated the persisted session and deleted auth.json on any
failure, including a timeout or an offline machine. A user who opened
the app without a network was signed out of a still valid session.

Only remove the file when the backend refuses the credentials or they
belong to another account. Transport errors keep the file for the next
launch.
Only the login form created at startup listened for a successful
sign-in. The form created after a sign-out had no listener, so a second
sign-in in the same run did nothing until the app restarted.

Subscribe in one place for every login form, and make the switch to the
login form idempotent so the Settings sign-out button and the later
backend confirmation do not replace the form twice.
The Settings screen saved a full snapshot taken at startup on every
toggle, which overwrote the pinned projects and project names that the
chat screen had saved since. Closing Settings then copied the stale pin
list back into the sidebar. Chat writes also raced each other with
separate load-modify-save threads, so a pin followed by a rename could
lose one of the two.

Route every write through a single writer thread that applies each
change as a read-modify-write of the file in call order. Callers now
name the field they change instead of passing a snapshot, and the quit
path waits for the queue to drain so a toggle just before quit is not
lost.
A permission request that arrived while another task was on screen was
dropped, and opening the asking task cleared the card again. The run
stayed blocked until the user pressed Stop.

Hold pending requests per session, like questions, and show the one for
the selected session. A reply or a decided row removes only its own
request, so a second request that replaced the card is not lost when
the first reply lands.
The Web chip kept the value of the previous task when the user switched
tasks, so the chip showed the wrong state and the next toggle sent the
opposite of what the runtime had.

Copy the session's stored value when a task is opened and when the
runtime reports an update for the selected task.
Switching tasks set the new selection before it ended the queued
message edit, so the release went to the new task and the chip stayed
held in the old one.
The task list auto-selected the newest task under the current project
without checking the archived flag, so archiving the last open task
could select an archived one and send new messages to it.

Removing a project also left a pinned project in the sidebar forever:
the pin was never dropped, and an archived task counted as a reason to
keep the project. Drop the pin, ignore archived tasks when deciding
which projects exist, and choose the fallback project from every known
project rather than from a sidebar list that a search may have
narrowed.
Url::host_str keeps the brackets around an IPv6 address, so the
address never parsed as an IP. A loopback MCP server on [::1] was
refused as non-loopback, and the public-host rules for IPv6 web fetches
were never reached.

Strip the brackets before the address is parsed.
Starting the runtime marked the launch root as trusted whenever the
saved answer was not trusted, which also replaced an explicit refusal.
Project skills then loaded without the prompt the README promises.

Only trust a root that has no saved answer.
Three paths could slice the composer text at a stale or wrong offset:
the IME selection was offset by the end of the replaced range instead
of its start, set_text and clear kept an open IME composition, and the
right-click menu items held a word range that later typing could move
off a character boundary. Each could panic on copy or on a suggestion
click.

Offset the IME selection from the start of the replaced range and
clamp it, reset composition and menu state whenever the text is
replaced, and check the menu range with str::get before slicing.
Two stub functions existed only to keep an import alive, a relative
time formatter had no caller outside its own test, and the task delete
path had no UI entry point.
Several runtime handle methods, two request types, and a billing
constructor had no caller outside their own crate or tests. Helpers
that only tests still use are now compiled for tests only.
save_config_for_scope used std::fs::write followed by a chmod. A crash
between the truncating open and the final write leaves a partial JSON
file that load_config rejects, and `maple-gpui acp` then refuses to
start until the file is removed by hand.

Route the write through private_file::write_private_json, which
writes a 0600 temp file in the same directory, syncs it, and renames
it over the target, so the old config survives an interrupted save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
new_session validated the created session id before it wrapped the
tool context lease in UnpublishedAcpSession. An empty id returned
early with the lease still unowned, so the tool context was never
revoked and the half-created session was never discarded.

Take ownership of the lease first so every early return goes through
the guard's drop and releases the context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BillingClient::new fell back to reqwest::Client::default() when the
builder failed. The default client panics under the same conditions
that make the builder fail, and when it did succeed it silently lost
the 15 second request timeout.

Make the constructor fallible and surface the error to the backend
constructor, which already returns Result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_auth held the mutation lock across credential validation, which
can take up to 30 seconds. A sign-out issued during that window
waited behind the sign-in, so logout appeared to hang.

Validate first and take the lock only to publish. To keep the
guarantee that a sign-out during validation wins, clear_auth bumps an
epoch and set_auth refuses to publish when the epoch moved while it
was validating. The existing account-scope check still rejects a
publish for a different signed-in account.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
is_remote_file_source only recognised UNC prefixes written as two
backslashes or two forward slashes. Windows also resolves a mixed
prefix such as /\server\share to a network share, so a read of that
path skipped the remote approval prompt.

Classify any path that opens with two separators from {/, \} as
remote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read only mode auto-approved read and read_image for any local path,
while the shell classifier refuses commands that read the same files.
An agent could read ~/.ssh/id_rsa or a project .env without a prompt
by using the read tool instead of the shell.

Add a deny-list of likely-secret paths (SSH, AWS, GnuPG, gh, kube,
docker, netrc, non-template .env files, key and credential files).
Reads of those paths now fall through to the normal approval prompt.

Web search sent the query off-machine without any approval in Read
only mode. Drop the auto-approval so the user is asked; a prompt is
the safe default until a real classifier exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The request_user_input tool awaited the question broker without
watching the tool call's cancel token. Stopping a task while a
question card was open left the tool waiting until the user answered
a card the UI had already torn down.

Race the ask against the cancel token. The broker's pending guard
drops the entry when the ask future is dropped, so a cancelled
question does not leak its sender.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the first selected line exceeded the read limit, the notice only
suggested a shell command. Every other truncated read ends with a
"Use offset=N to continue" hint, so a model that hit a long line at
the start of a range had no cue to move past it and could loop on the
same read.

Check whether any data follows the long line and append the next
offset when it does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
read_image built a reqwest client with the default redirect policy and
fetched any http or https URL. The approval prompt showed the user one
URL, but a 302 could send the approved request to 127.0.0.1 or the
cloud metadata address, and a loopback URL was never rejected at all.

Validate the host with the same public-host rules the web tools use,
reject embedded credentials, and disable redirects so the bytes come
from the host the user approved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK rotates the access and refresh pair during API calls and the
runtime reports it through MapleApiAuthEventSink, but the app passed
NoopAuthEventSink everywhere. auth.json kept the pair from sign-in, so
once the refresh token had rotated the next launch restored stale
credentials and the user had to sign in again.

Carry the live snapshot in the event and give the app a sink that
writes it to auth.json off the runtime thread. Only the revision is
logged; the tokens never reach the log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
notify-send parses positional arguments that start with a dash as
options. A run summary that begins with "- " or "--" made the
notification fail or, worse, set an unrelated flag. Ending option
parsing with "--" sends any text as the title and body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parse_version dropped everything after the patch number, so 1.2.0-beta.1
compared equal to 1.2.0 and a beta build never learned that the final
release shipped. The version now keeps the pre-release suffix and sorts
it below the bare triple, as semver does. Build metadata is still
ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MAPLE_API_URL, MAPLE_BILLING_API_URL, MAPLE_UPDATE_REPO,
MAPLE_DISABLE_UPDATE_CHECK, and MAPLE_CLIENT_ID each had their own copy
of "read, trim, drop when empty", and the API URL did not trim or drop
at all, so a blank value became an empty backend URL. One module now
owns that rule: env_string for values and env_flag for 1/true/yes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A malformed override fell back to the production client id without a
trace, so a test build that meant to talk to a staging project silently
talked to production. The fallback stays, with a warning that names the
bad value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The redirect URL's query values were used raw. Google authorization
codes contain "/" and are sent as "%2F", so pasting a Google callback
failed the token exchange. Both values are now form-decoded, including
"+" as a space, with the percent-encoding crate that is already in the
dependency tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AnthonyRonning and others added 25 commits September 5, 2026 05:25
…elds

gpui at the pin ships an AccessKit tree and Maple exposed nothing to
it. The transcript is a log of articles labelled by sender with their
position in the conversation; thinking headers and tool cards are
disclosure triangles; the waiting indicator is a status. The sidebar's
task list is a list box of options with title, project, selection, and
the Application-Vim active descendant; its headers are labelled buttons
and its popups menu items. Settings navigation is a tab list. Every text
input announces itself by its placeholder. Nothing is computed unless a
screen reader is attached.
A plain element drives any surface through a five-method trait,
implemented for the list state and the scroll handle. A press on the
thumb captures the pointer, so the drag survives leaving the track and
ends on the mouse up wherever it lands; a press on the track pages by a
viewport; the wheel passes through; while the content fits, nothing is
painted or blocked. The transcript's hand-drawn bar, its three
container-level mouse handlers, and the screen's drag field are gone.
The sidebar task list and the settings pane, which had no scrollbar,
get the same one.
The bar appears when the user scrolls, while the pointer is on its
strip, and during a drag, then fades after a second of quiet; instant
under reduce motion. A list following its tail moves its top and range
together and stays at the end, which counts as growth rather than a
scroll, so a streaming transcript never flashes the bar. The fade drives
itself frame by frame only while fading, and one timer task arms the
hide. A hidden strip blocks nothing but still notices the pointer, so
hovering it brings the bar back.
The document becomes Send (Rc to Arc) so the parse runs on the
background executor. On a miss the cache spawns the parse and keeps the
previous document on screen; a chunk that lands mid-parse is served the
same document, and the re-render that installs the result starts the
next parse, so the rate is bounded by parse latency rather than the 50 ms
timer, its stale flag, and its deferred repaint, all of which are gone.
A short cold source still parses inline so ordinary rows never show raw
text. After a timeline load the newest long messages parse in the
background at low priority so the first scroll finds them ready.
…ables

Inline code renders in the monospace face inside the same shaped line
as its prose through font-family overrides, so no paragraph splits. The
single-line clamp sites gain an ellipsis (gpui's clamp only clips): the
project chooser's path keeps its file name by truncating at the start,
and thinking and tool titles truncate in the middle. Code block bodies
scroll sideways instead of wrapping, restricted to their axis so the
transcript still takes the vertical wheel. Table column weights are
computed once at parse time rather than on every frame, and tables and
cells carry roles and indices for the accessibility tree.
Monospace inline code, real ellipses, sideways code blocks, cheaper tables
The task sidebar was a set of methods on the chat screen, so any notify
on the screen rebuilt its element tree, and the running-task spinner
notified the screen fifteen times a second. It is now an entity of its
own that owns the list, sections, search, menus, renames, the pinned and
settled sets, and its Application-Vim row targets. The screen pushes
what it shows (sessions, selection, running and unread sets, recent
roots) and embeds it with `cached`, so the subtree is reused until the
sidebar itself changes. The sidebar talks back through events, never by
updating the screen from inside its own update, so nothing re-enters;
clicks that need the window call the screen from plain closures. The
trust prompt, the remove-project confirmation, archiving, and leaving a
task stay on the screen, in a dialogs module, since they touch the
canonical session list and project context.
`Window::refresh` discards every cached view in the window, so the copy
button's two refreshes had to go before any panel was cached. The render
context now carries the id of the view being rendered down to the
button, which asks for one repaint of that view instead. CLAUDE.md
records how panel entities work: the screen pushes state in, panels
answer with events or window-carrying closures, listeners never call
the screen, and a cached view needs a definite size.
The screen fixture loads the settings file of the machine running the
tests, and tests that settle or pin persist their fixture ids into that
file, so a later fixture could inherit a settled "s1" and shift every
sidebar row. The fixture now clears the persisted sets after loading.
Sidebar becomes its own cached entity; copy buttons stop refreshing the window
The screen pushes its session list on every sync (run start, session
update, selection change), and the sidebar rebuilt its sections and
repainted each time. It now compares the incoming list field by field
and returns early when nothing changed, like its other setters do. The
one test that renamed a project by reaching into the map now goes
through a helper that rebuilds, as a real rename does.
…ssions

Sidebar ignores an unchanged session list
Since the gpui pin, Linux had no windowing backend compiled (gpui_platform
exposes wayland and x11 as features the app must enable) and could not
build at all because ashpd was asked for both its async backends: gpui's
Linux port selects async-io through oo7 while the Cua driver selected
tokio. The app enables wayland and x11, which forward to a Linux-only
crate and change nothing elsewhere, and the driver pin moves to a rev
that selects async-io.
Build on Linux again: windowing features on, one ashpd backend
Show who is signed in (email or anonymous account id, sign-in method,
member since), offer to resend the verification email, and call the
server logout route during sign-out (the backend does not revoke the
refresh token yet). The SDK user lookup that validation already
performed is now surfaced through MapleApiSession::account instead of
being discarded.
Email and anonymous accounts get a change-password form in the Account
section. The backend rotates the token pair on success; the SDK stores
it and the session publishes it to the persisted auth record, so the
next launch restores the new credentials.
Typing DELETE asks the server to email a confirmation code; the code
plus a client-held secret (SHA-256 hash sent up front, plain text on
confirm, as the web app does) deletes the account. The agent runtime
stops before the irreversible call and the local credentials are
dropped after it, without a server logout the deleted account could
not honor. The app returns to the login form on AccountDeleted.
The billing client now decodes the whole subscription status and adds
the public product list, the Stripe customer portal, checkout, and the
API credit balance. The Billing section shows the plan, its renewal or
expiry date, credits, and the plans on sale. Free accounts start a
Stripe checkout in the browser; Stripe subscribers change plans in the
portal; Bitcoin, pass, and team plans point at the web pricing page.
Return URLs are the web app's, as in the Tauri build, until the desktop
app has its own URL scheme.
List, create, and delete the account's API keys for the OpenAI-compatible
API and the local proxy. A new key is shown once with a copy button, a
delete asks for confirmation on the row, and the section is gated on a
Pro, Max, or Team plan with a link to the Billing section otherwise, as
the web app does.
Prompts for the email and password (never a flag, so the password stays
out of shell history) and saves the same session the desktop app writes,
so `maple-gpui acp` can run on a machine that never opened the window.
OAuth stays desktop-only for now.
Add maple-gpui login for terminal sign-in
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Maple development preview: https://038da9fa.maple-ca8.pages.dev

Commit: 12644a596e46af10d0ca36fc6f8054b6b611eca8

Uses development API, billing, flags and PCR configuration. Cloudflare Access applies.

@AnthonyRonning
AnthonyRonning merged commit 509df1b into master Sep 8, 2026
23 of 25 checks passed
@AnthonyRonning
AnthonyRonning deleted the codex-mono-agent-maple branch September 8, 2026 08:01

This branch was successfully deployed

1 active deployment
pages-pr-885 — 12644a59 Deployed Sep 8, 2026 by github-actions[bot]
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.

2 participants