Add remote development to the Maple Agent app - #983
benthecarman wants to merge 37 commits into
Conversation
Let Maple tasks delegate to an installed Claude Code CLI through a Rust transport adapted from Goose. Share Codex's activity, approval, question, and cancellation controls while keeping process ownership in Maple. Cover streaming, resumption, provider isolation, permissions, failures, and process cleanup with native CLI fixtures.
Show Codex and Claude in the composer only when enabled in Settings. Require each task to opt in, and block saved selections while Settings is disabled. Preserve task choices across restarts and re-enablement. Exercise admission, composer visibility, and cached tool refresh for both providers, including rejection of direct requests when disabled.
Explain Claude CLI setup, native transport provenance, and the separate Settings and task controls. Record the fixture validation workflow so future provider changes preserve the shared lifecycle contract.
The gpui UI drove the agent runtime through one concrete facade that also held the account: sign-in, billing, audio, and every task call went through the same struct, and the UI reached around it in four places to touch the host's filesystem (the folder picker's local path, the git branch and its watcher, the sessions database for the context ring, and the tool summary store). Remote development needs the task surface to stand on its own so a remote host can implement it. Introduce HostBackend in the runtime crate: everything a client drives on one host, with LocalHostBackend wrapping the in-process runtime. The account-level backend keeps sign-in, billing, and audio, and hands out the local host. Hosts push events through one fan-out hub so a server can project the same stream later. The four reach-arounds become host methods and events: directory suggestions, a host-side git watch that reports the branch, and SQLite readers for context usage, tool summaries, and the usage page. Session defaults (permission mode, web access, harness instructions) move from the app settings into the host's account config, since two hosts may differ, and older settings files are migrated once. Task and project state the settings keep is keyed by host id. Every wire-facing type now derives both serde halves, which freezes the vocabulary the protocol will carry. The design and the remaining steps are in docs/remote-development.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A client that drives a host on another machine needs a wire that carries the whole HostBackend surface, never silently loses an event, and never lets one slow client hold up the runtime. The new maple-remote crate is that wire, written against an abstract carrier so the WebSocket and Noise layers can land underneath without touching it. Frames are [channel][kind][payload]. Channel 0 carries JSON-RPC 2.0 with methods grouped by domain; each domain has its own request enum and its own controller on the host, so no single dispatcher grows without bound. Other channels are binary streams with credit-based flow control, used for attachments now and reserved for a terminal later. The handshake carries protocol version, app version, the compile-time enclave environment, and a feature table. Environment and protocol mismatches are refused; everything else evolves through features. Schemas are append-only and unknown fields are ignored. Every event on a connection carries a sequence. The client publishes a resync event on a gap, and the UI re-reads what it shows instead of trusting the stream. Snapshots are paged by count and by bytes from a copy the connection keeps, so a long transcript never has to fit one frame. Requests are answered concurrently so a slow call cannot delay the keepalive; a lease on the host and an application ping on the client keep liveness separate from request timeouts; and a connection whose outbound queue overflows is closed rather than blocking the host. Loopback tests run a server and a client over an in-process carrier with a scripted host: refused handshakes, paged snapshots, ordered events, a forged sequence gap, attachment streams, a client that stops draining, and an expired lease. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Clients and hosts now meet over a plain WebSocket with Noise inside, so a relay in between later sees only ciphertext and a LAN needs no certificates. Pairing runs the XXpsk3 pattern with a one-time code as the pre-shared key: both sides learn and pin each other's static key, and the code is the only proof. Every later connection runs IK against the pinned host key, and the host accepts a device only if it is in its device list. The hello a client sends must name the key the handshake proved, so the protocol identity and the transport identity cannot diverge. The pairing pattern ends with a client message, so a client could not tell a wrong code from success until the host dropped it; the host now sends one empty transport message to confirm, and the client trusts nothing before decrypting it. Wrong codes count against the source address and lock it out after a few tries. `maple-gpui serve` runs this machine as a host: it reuses the saved sign-in, holds one server per data root behind a lock file, and stores its key, its paired devices, and the pending code under the local data root at mode 0600. `serve pair` publishes a code the running host reads without a restart; `serve devices` lists and revokes, and a revoked device's live connection ends at the listener's next check. The end-to-end test binds a real port: a device pairs with a published code, reconnects with the pinned key, a stranger and a wrong pin are refused, a mismatched hello is refused, revocation refuses the next connection, and attachments larger than one Noise message cross intact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The desktop app is now a client of remote hosts. Each account keeps a hosts file: a host is its static public key, a name, and the addresses it can be reached at, so pairing again over another address merges into the same host rather than adding a second one, and a malformed entry is dropped without losing the file. A connection manager runs one connector per saved host on the backend runtime. It dials the host's addresses in order, hands the chat screen a connected backend, forwards the host's events, and reconnects with jittered exponential backoff when the connection ends. Pairing dials with the code, saves the host, and starts its connector on the connection the pairing opened. The chat screen keeps one entry per host and maps every task to its host. Its single host handle follows the selected task, so every existing call site drives the right host without knowing which one. Task lists are read per host and merged; a host's runtime status only speaks for that host's runs; an offline host's tasks leave the list until it returns while the task on screen stays readable. New tasks go to the sidebar's host filter when one is set, else to the selected task's host. The sidebar lists hosts in the project switcher, grays offline ones, badges each row with its host once more than one host is known, and persists pins and settles to the host that owns the task. Settings gains a Hosts section to pair, list, and remove hosts, and host-scoped sections get a host selector when more than one host is connected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The host role now lives in one place the command and the window share. Hosting takes the data-root lock, binds, and serves the account's local host on the backend runtime; the serve command runs it in the foreground and the window runs it behind a new "Allow remote connections" setting, off by default so a fresh install never listens. Turning it on in Settings takes effect at once and reports where the host listens and under which key. The Hosts section publishes pairing codes through the same pending file the command uses, lists the devices that paired with this machine with revocation, and shows a host paired from Settings as online. Both roles share the host key, the device list, and the lock, so only one of them serves at a time and the other says who holds the root. As a service the command ends cleanly on SIGTERM as well as Ctrl-C, and when NOTIFY_SOCKET is set it reports READY=1 once the port is bound and STOPPING=1 on the way out, so a unit can be Type=notify. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Choosing a project on a remote host opened the laptop's native folder picker, which can only produce local paths the host rejects. Which host a new task would run on was only implied by the sidebar filter and the selected task, and the header could name a task from the sidebar while the pane showed the new-task hero. Every host now shares one project picker: a dialog with a search box over the host's recent projects and its folder suggestions, an "Open this path" row when the text looks like a path, a "Browse..." row for the system picker on the local host, and focus in the search box on open. The header gains a host chip once more than one host is known, placed before the project chip: a status dot and the target host's name, with a dropdown to switch hosts or reach the Hosts settings. The header title follows what the pane shows, and the boot auto-select considers only the target host's tasks. The flow follows Paseo's add-project overlay and host picker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Most people work on one host at a time, so a launch that always targeted the local host sent every remote user through the host chip before their first task. The client settings remember the host of the last new task. At launch the chat screen holds its local auto-select while that host connects, then makes it the target, shows its saved project, and opens its latest task there. A remembered host that reports offline or is no longer saved releases startup to the local auto-select. The connection manager now announces the saved host list before dialing, so the screen can tell a host still connecting from one it does not know. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Enabling Claude or Codex on a task that lives on a remote host failed with "Session not found": calls about the task on screen went through the backend that new tasks target. Every call scoped to a task now resolves the backend from the task's owner, and the sidebar hands a rename to the screen instead of calling its local backend. The project picker's search box lost typing to the composer, because the chat root forwards plain typing there unless a known input holds focus; the box joins that list and nothing types past an open picker. Its arrows never arrived, because up and down are actions of the text input; the input gains a vertical hook that a list above it takes, and an arrow fills the box with the highlighted path as a shell completes. A typed path may start with a tilde, which the host expands to its own home directory. Tasks on a remote host appeared twice in the sidebar when a load that landed after a connection blip filed the task under the wrong host; a host's list now replaces every row it names, and a load files the task under the host that answered it. The unfiltered sidebar scope reads "All projects" whatever the host count. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A review of the crate found denial-of-service holes and lifecycle leaks, none of them an authentication bypass. The carrier accepted WebSocket messages of any size before any authentication and reassembled frame pieces without bound; both are now capped, and an oversized control frame fails at the sender rather than making the peer close. A pairing code could be consumed by two racing connections; it is now compared and consumed under the store lock inside the handshake. Failures of any handshake fed the pairing limiter, so a revoked device's reconnects locked its address out of re-pairing; only pairing-mode failures count, and the limiter prunes and caps its map. A device could take another's public key as its name to make it unrevocable; revoke matches keys first and names are capped. A hello after ready re-ran the device hook; it is refused. Every host connection leaked its writer task, its snapshot cache, and a two-second close wait, and never sent the WebSocket Close; the writer now holds only a cancellation token, teardown closes on every path, snapshots are an eight-entry LRU, and watched roots are released. A replaced connector could report a live host offline; it checks it is still the registered one before emitting. Removing a host no longer waits out a dial. Param decode errors are no longer reported as an unknown method, pairing codes parse non-ASCII correctly and never print in Debug, a host-provided length no longer sizes a client allocation, and integration setup is refused over the wire since the trait documents it as local. Unused surface goes, host answers are encoded once, and the crate has one clock helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every method's params lived in a one-off struct made by a macro, and the enums wrapped those structs in newtype variants, so the method name and its fields sat in two places and no-params methods sent an empty object. Each request enum now names its fields directly on the variant, with camelCase field names, and a method without params is a unit variant that sends no params key. The JSON for methods with params is unchanged. Decoding tries an empty or missing params both ways, since serde takes a unit variant without params and an all-optional struct variant with an empty object. The construction sites in the client and the server destructure the variants in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
net.rs held both the host's listener and the client's dialer, so a reader of either role had to read the other and the pairing-failure accounting sat beside code that never pairs. Move the host role (serve_listener, handle_connection, HostStores, the pairing-failure accounting) to listen.rs and the client role (connect_direct, ConnectTarget, Dialed) to dial.rs. net.rs keeps the WebSocket configuration and the handshake budget both roles share and re-exports both roles, so the desktop app and the tests compile unchanged. No behavior changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude gets its session id up front through --session-id and writes the session itself. The driver treated the id as resumable as soon as the process was spawned, so a CLI that died before Claude persisted anything left the agent passing --resume forever, and every later send repeated the same failure. The Claude client now records when it saw a system/init message or a successful result for its session, and the agent passes --resume only after that. A resume that fails without seeing the session clears the flag, so the next turn starts the session again under the same id rather than failing the same way. An unknown control_request subtype used to end the read loop and kill the turn. It now gets an error control_response for its request id and the turn carries on, so hook callbacks or future subtypes cannot take a delegated turn down. The "process exited before the turn finished" notice named Codex for both providers; it now uses the provider's catalog name, and both the provider check and the display name come from the integration catalog instead of two hand-written lists. Persisted Claude permission rows were titled with the raw claude_tool key; the request now carries the Claude tool's name so the row reads "Claude Code: use Bash". Swallowed transport failures log at debug with metadata only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BranchWatchers::watch released the map lock between looking a root up and inserting its watcher, so two clients watching one root at once left a watcher that the first unwatch dropped from under the other; the lookup and insert now happen under one guard, and a root that became a checkout while watched attaches its watcher on the next watch. set_session_defaults wrote the default model from the Settings snapshot, which could revert a model the chat screen had just saved; the model now has one writer. The runtime start loads the config once, permission decisions and modes use one set of constants, store errors name their operation and database, and swallowed failures log their metadata. The design doc's reach-around table notes what was built. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
git.rs, directories.rs, and store.rs serve only the in-process host: the filesystem, the git dir, and the account's SQLite stores. They now live as private modules under host/local, so host/mod.rs holds the trait, the events, and every type that crosses the wire, and nothing else. DirectorySuggestion, UsageRow, and UsageSummary move into host/mod.rs for the same reason; they were defined next to the readers that fill them but are part of the client-facing surface. Every path other crates import from maple_agent::host is unchanged; the re-exports stay where they were. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hosting serves one account's runtime, but the device list and the pending pairing code lived beside the machine-wide host key. After a sign-out and another account signing in, "Allow remote connections" started hosting again and the previous account's devices reached the new account's runtime, and a code published for one account could pair a device into another. Both files now live under `<local data>/remote/accounts/<scope>/`, like the client's hosts.json, so a host only admits devices paired into the account it serves. `serve pair` and `serve devices` resolve the account from the saved sign-in and refuse to run without one. The host key and the lock stay per machine. Nothing shipped with the old layout, so there is no migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hosting::stop returned before the port was free, so a host started right after could take the lock and fail to bind; stop now waits for the listener. Whether a host was running was read from a pid, which only Linux could check; the serve.lock file lock is probed instead. Starting the window's host bound the port on the UI thread; it now runs on the backend runtime with a Starting state, and the toggle persists only once the host listens. The serve command did not compile alone, and refused to start when the account server was unreachable at boot, telling the operator to sign in; it now serves with the saved sign-in and retries in the background. Legacy session defaults were lost when the user upgraded while signed out; the login path adopts them too. macOS hosts were all named "maple"; the name comes from gethostname. The Hosts pane showed a stale online snapshot, a pairing code forever, and a code button while another process held the host; it reads live status, hides an expired or consumed code, and enables the button only while listening. Duplicate helpers and constants across the host and client modules are gone, and sd_notify lives with the command. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`hosting.rs` (this machine as a host) and `hosts.rs` (this machine as a client) sat beside each other at the top level with names one letter apart. They move to `remote/host.rs` and `remote/client.rs`, and `remote/mod.rs` takes what both roles share: the remote directory, the account-scoped store paths, and the default listen address, which the `serve` command line reads in every build. `serve.rs` keeps the clap definitions, the command, its signals, and `sd_notify`. A move only: no behaviour changes. Call sites update their paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The screen's `host` field had two writers: selecting a task pointed it at that task's backend, and choosing a target pointed it at the target's, so a call about the selected task after the target moved reached the wrong host. `host` now means one thing, the target's backend; calls about a task go through the owner lookup, archiving a project archives each task on its own host, and signing out stops the local runtime. An offline host could become the target through the sidebar filter, the sidebar and screen disagreed once a filtered host dropped, a late bootstrap could resurrect an offline host's tasks, a failed bootstrap of the remembered host left startup waiting, per-host caches went stale between switches, the offline notice repeated on every retry, and the title flipped to "New Task" when the selected task's host dropped. Each is fixed, with a connection counter that drops stale answers and write-through caches. Host lists and labels are built when hosts change instead of per frame, the sidebar answers project trust through the screen instead of its own backend, and events file a session under the host they came from before the upsert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The project chip opened a dropdown of recent projects with a "New project…" row, which opened the typed picker, whose local host added a "Browse…" row for the native folder dialog. Three ways to reach the same choice, two of them local-only, with a key context, three actions, a focus handle, and a folder-picker guard of their own. The chip and its shortcut now open the picker directly, on every host alike, and the native folder dialog goes with the dropdown. The picker highlights the current project's row when it opens. The RootMenu key context, its shortcut slots and settings label, and the tests that drove the dropdown go; a windowed test drives the picker from the shortcut. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The chat screen's module held the multi-host state machine and the project picker alongside everything else, at close to six thousand lines. Both have their own vocabulary and tests, and neither needs the rest of the file to be read with it. `hosts.rs` takes the host entry, the connection bookkeeping, the target and filter logic, the connection manager's events, and the header's host chip menu. `picker.rs` takes the picker's rows and dialog. Nothing changes behavior: the functions move as they are and become visible to the parent module, and the fields they use stay on the screen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nothing speaks this protocol yet, so nothing needs the old shapes. The integration setup method leaves the wire instead of being refused: the client answers locally and the server reports it as unknown. The network module no longer re-exports the listen and dial halves; callers name the role they use. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Images travelled inline in the run.send request as base64 data URLs, so the 4 MiB control frame limit capped them well under the host's own 10 MB limit per image, and a large attachment failed at the sender. Either side can now open and receive streams, clients on odd channels and hosts on even, with one sender and one receiver implementation shared by both roles and the receiver acknowledging every stream with its own Close. The client streams each image first, on a channel it opens with purpose "upload", an id it minted, the media type, and the byte length, then waits for the host's Close acknowledging the bytes. The request names the uploads by id; the host resolves them, rebuilds each data URL, and consumes them before it calls send_message. Inline attachments are refused with INVALID_PARAMS, as is an unknown or incomplete id, and a request naming several ids consumes all of them or none. The host keeps uploads per connection: at most 10 MiB each, four in flight, sixteen finished and unreferenced with the oldest dropped, all gone with the connection. A stream that declares more than the limit, sends more than it declared, or ends short is refused on its channel and the connection stays usable. The handshake gains the uploadStreams feature: the host refuses uploads from a client that lacks it, and the client refuses to send attachments to a host that lacks it, telling the user to update the host. HostBackend::send_message keeps its shape on both sides; the conversion is internal to the remote client and server. Loopback tests cover a 6 MB attachment, an oversized upload, an unknown id, inline images, and teardown. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The design doc had accumulated "As built" notes while its main text kept describing the original plan: a host router, connector traits, host-level cursors, logical sessions, and files in places they no longer live. An audit against the code listed every mismatch. The main text now says what the code does, with the planning decisions kept as recorded, a limits section for the constants the code enforces, a testing section naming the tests that exist, a "Not built" section for what the plan promised and the code does not do, and the relay constraints kept for later. The image upload streams are described as built: the wire shapes, the channel parity, the receiver's acknowledging close, the limits, and the feature gate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The published code was muted text in the theme's secondary color, hard to read in dark mode, and nothing on the pane let the user select or copy it. The code is now a monospaced chip in the primary text color, and the shared copy button beside it puts it on the clipboard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The header's host chip named the host new tasks would run on. With a task open it read as the host that task runs on, which is wrong as soon as the target moves: a tester switched the chip to a remote host, kept chatting in a local task, and the agent answered from the local checkout while the chip said the remote host. With a task open the header now shows that task's host as a badge; a task never moves, so there is nothing to switch. The chip that picks the host for new tasks appears only on the new-task screen. Both carry a tooltip saying which they mean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "slow" fixture mode left a child behind to prove cancellation kills a CLI's descendants, and reached for the sleep binary by an absolute path that a host without /bin/sleep does not have, so the test timed out waiting for the child's pid. The child is now the fixture binary itself in a sleeping mode, which every host that can run the test has. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Bug (from a tester): the header's host chip read "mac-agent" while the open chat was answering from the local checkout. The chip named the host new tasks would run on, but with a task open it reads as the host this task runs on. Messages always go to the task's own host, so switching the chip while chatting in a local task changed nothing about that task and made the header lie. Fix: with a task open, the header shows that task's host as a plain badge (tasks never move, so there is nothing to switch). The chip that picks the host for new tasks now appears only on the new-task screen. Both carry a tooltip saying which they mean. Covered by Also in 4f12b89: the Claude cancellation test spawned |
|
I did the actual switch on the new task screen. I didn't try switching an already existing chat onto the remote instance. This actually happened twice in a row. I click "New Task" and then switch the header to remote, then send new message. Did that twice in a row and it ran the agent locally while the top header had the remote instance pulled up. |
|
Also it would be cool if there was like a connection string to paste and not multiple fields to fill in. Though that does raise the question on what's the IP being shown, right? is it auto detecting tailscale or pulling an internal ip address? Would probably have to come after we have more than just tailscale setups, but it would be nice to shift at least the handshaking into the enclave. |
"New Task" creates a task on the target the moment it is clicked, so the new-task screen already has a task behind it on that host. Picking another host in the header then changed only where the next task would go; the first message went to the task that already existed, on the host it was created on. A tester switched the chip to a remote host on the new-task screen twice and both runs stayed local while the header named the remote host. A blank task now follows the target: switching the host, the sidebar filter, or the remembered host at launch leaves the blank task behind and creates one on the new host, and the header keeps offering the target chip while the task is blank rather than a badge for the host the blank task happened to be created on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks, that was a second bug and the real one. Fixed in 63bcff5. What happened: "New Task" creates a task on the current host the moment you click it, so the new-task screen already had a local task behind it. Switching the header to the remote host then only changed where the next task would go; your first message went to the task that already existed, on the local host. So the header was right about the target and the message went to a task the header was no longer describing. Fix: a blank task (no messages, no run) now follows the host switch. Picking a host in the header, or in the sidebar filter, drops the blank local task and creates one on the host you picked, so the first message runs where the header says. Same for the remembered host at launch. Covered by |
|
can we just not make a task until the message actually sends? |
The hello only named the package version, and every checkout of the prototype is 0.1.0, so a client could not tell which build a host was running or whether the two sides matched. Both hellos now carry the git revision `build.rs` bakes in as an optional `build` field; an older peer that omits it still parses. The host logs the client's version and build on connect, and the client exposes the host's through `host_version()`. The `--version` format moves into one helper in the app's `env` module so the hello, the startup log line, and clap print the same string. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The manager knew whether a host was online but not what it was running, so nothing could say that a host was behind the app. It now remembers the version and build from every successful hello, answers them through `host_version(id)` for callers that poll the way `is_online` is polled, and forgets them once the connection ends. The saved host also keeps `lastSeenVersion` and `lastSeenBuild`, written on every hello, so an offline host still shows what it ran last. A hosts file written before these fields still loads. The `Status` event is unchanged: the chat screen destructures it field by field, and it does not need the version. The transport test's loopback host moves into the shared fixture so the manager can be tested against a real handshake. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A host row said only online or offline, so nothing told the user that
a host was running an older build than the app talking to it. Each row
now shows the version and build the host announced, muted beside its
state ("last seen" when offline), and a short line when it matters:
behind this app, a different build of the same version, newer than
this app, or a newer release the update check found.
The rows are computed when the list or a host's state changes, not in
render. Nothing pushes status changes to the settings screen, so while
the Hosts section is shown it polls the manager once a second and
re-renders only when a row changed; a flipped connection re-reads the
saved list, since the hello that just completed rewrote that host's
last seen version.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Name the optional `build` field in both hellos, the connect log line that includes it, the `lastSeenVersion` and `lastSeenBuild` fields on a saved host, the manager's `host_version(id)`, and the version line and comparison copy the Hosts pane shows, so the protocol and settings descriptions match the source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"New Task" created a session on the target host the moment it was clicked and selected it behind the empty screen. Switching the host or project afterwards did not move that task, so a tester's first message ran locally while the header named the remote host, and every click that sent nothing left an empty row in the host's database. New Task now clears the selection and shows the empty screen for the target host's project without creating anything. The composer works on a draft: the permission mode, model, web access, integration toggles, and staged images live on the screen only. The first send builds the create request from the draft, creates the task on the current target, applies web access and integration toggles the request cannot carry, and then runs the send against the new id. A pending first send is kept as a continuation and run from finish_new_session; the existing selection generation drops it when the user navigated away meanwhile, and the one-create-at-a-time fence keeps a second Enter from starting another task. A failed create reports the error and gives the text back to the composer. Slash commands that act on a task and /btw create the task the same way before running; commands that need none run at once. The boot auto-select starts a draft when no task matches the visible project. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A host switch on the empty screen used to drop the blank task created on the old target and create it again on the new one, so the first message would run where the header said. With the task created only on the first send there is no blank task to move: the empty screen is a draft with no session behind it, and the target host is read when the message is sent. Remove selection_is_blank and its callers. A target change now only adopts the new host's project context and reloads the draft's integration rows; the remembered-host restore at launch no longer checks for a blank local task; and the header's task badge follows the selection directly, since an empty pane means no task rather than a blank one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Older builds persisted a task on every New Task click, so hosts still hold empty tasks. The boot auto-select opened the newest task under the visible project without caring that it was empty, and the header then showed it as an open task: a badge for the host it sat on instead of the chip that picks a host, over a pane that looked like the new-task screen. A host switch would also have left the first message on that task's host. The auto-select now skips empty tasks on both the client and the host side, an empty selected task shows the target chip like the new-task screen, and switching the target leaves such a task behind so the send creates one on the new host. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stacked on #976 (Claude Code delegation); the first three commits here are that branch. The remote-development work is the 24 commits after it.
What this adds
Remote development for the GPUI app over LAN or Tailscale: one machine runs
maple-gpui serve(or turns on "Allow remote connections" in Settings), a paired device drives its runtime, projects, git checkouts, and integrations through the sameHostBackendthe local window uses. The transport is a WebSocket carrier with Noise inside (XXpsk3 for pairing with a one-time code, IK with pinned keys afterwards), framed channels with JSON-RPC on channel 0 and credit-controlled binary streams for attachments and uploads, per-connection sequence numbers with a resync on gaps, and a bounded outbound queue that closes slow clients. The relay through the enclave is not built; the design keeps its constraints so it slots in beneath the same protocol.Design and as-built notes:
apps/maple-agent/docs/remote-development.md. Operator docs: theserveand Hosts sections ofapps/maple-agent/README.md.Shape of the change
crates/maple-agent/src/host/: theHostBackendtrait and the in-processLocalHostBackend, with git branch watching, usage readers, and directory suggestions moved behind it.crates/maple-remote/: the wire protocol, Noise handshakes, pairing, device and host stores, connection manager,HostServer, andRemoteHostBackend, with loopback and transport test suites.app/src/remote/andapp/src/serve.rs: the host role (lock, listener, systemd notify, pairing CLI) and the client role (device key, saved hosts, manager).app/src/ui/chat/hosts.rsandpicker.rs: tasks from several hosts merged in the sidebar with a host filter, a target host chip, one project picker for every host, and a remembered last-task host restored at launch.Decisions worth knowing
Not built, listed in the doc
Incremental reconnect catch-up, logical sessions across a drop and the permission guard, remote CUA status, host rename UI, the relay.
Tests
just cipasses: format, clippy with warnings denied for every feature set, and the unit, loopback, and transport suites (56 in the protocol crate). Manually exercised on a laptop paired with a Tailscale host: pairing, reconnect, image uploads, integrations on a remote task, the picker with~paths.🤖 Generated with Claude Code