Skip to content

Add remote development to the Maple Agent app - #983

Draft
benthecarman wants to merge 37 commits into
MaplePrivacyLabs:masterfrom
benthecarman:remote-dev-maple-agent
Draft

benthecarman wants to merge 37 commits into
MaplePrivacyLabs:masterfrom
benthecarman:remote-dev-maple-agent

Conversation

@benthecarman

Copy link
Copy Markdown
Contributor

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 same HostBackend the 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: the serve and Hosts sections of apps/maple-agent/README.md.

Shape of the change

  • crates/maple-agent/src/host/: the HostBackend trait and the in-process LocalHostBackend, 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, and RemoteHostBackend, with loopback and transport test suites.
  • app/src/remote/ and app/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.rs and picker.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.
  • Settings: a Hosts pane for both roles, per-host session defaults and UI state, per-account device scoping.

Decisions worth knowing

  • Off by default; pairing is the only proof; no remote sign-in (the host must already be signed in).
  • Every task-scoped call routes to the host that owns the task; new tasks go to the target host.
  • Project selection is one typed dialog for every host; the native folder picker is gone.
  • Integration setup is refused over the wire and done on the host.
  • No compatibility shims: nothing shipped speaks this protocol yet.

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 ci passes: 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

benthecarman and others added 29 commits September 20, 2026 14:41
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>
@benthecarman

Copy link
Copy Markdown
Contributor Author

Pushed 067f33b and 4f12b89.

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 test_header_names_the_open_tasks_host.

Also in 4f12b89: the Claude cancellation test spawned /bin/sleep, which does not exist on NixOS, so it timed out there. The fixture now spawns itself as the long-lived descendant. That one belongs with #976 if you want to cherry-pick it there.

@AnthonyRonning

Copy link
Copy Markdown
Contributor

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.

@AnthonyRonning

Copy link
Copy Markdown
Contributor

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>
@benthecarman

Copy link
Copy Markdown
Contributor Author

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 test_switching_host_on_a_blank_task_recreates_it_there.

@AnthonyRonning

Copy link
Copy Markdown
Contributor

can we just not make a task until the message actually sends?

benthecarman and others added 7 commits September 21, 2026 01:40
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>
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