Skip to content

desktop: environment picker — grouped add-menu, remembered selection, readiness after SSH - #113

Merged
pallaoro merged 132 commits into
mainfrom
would-it-be-possible-to-create
Aug 7, 2026
Merged

desktop: environment picker — grouped add-menu, remembered selection, readiness after SSH#113
pallaoro merged 132 commits into
mainfrom
would-it-be-possible-to-create

Conversation

@pallaoro

@pallaoro pallaoro commented Aug 6, 2026

Copy link
Copy Markdown
Member

Three related improvements to the New Task Run on picker (bundled — same UI surface):

  1. One + Add a remote connection row replaces the three separate add rows (Create a box on Hetzner · Set up over SSH · Add a Tailscale box) — it expands to those methods on click, keeping the list short. The SSH option uses the same Server icon as the box rows instead of a stray Download glyph.
  2. The chosen environment is remembered — the composer persists your pick to localStorage (ateam.runOn) and defaults to it on the next task, validated against the project's available environments (falls back to Local if the saved box isn't an option here).
  3. Readiness checklist after Set-up-over-SSH too — extracted the checklist into a shared BoxReadinessChecklist so both Create-a-box and SSH-setup end on it. An SSH-prepared box is selected as soon as it connects, then the checklist shows what's left (gh auth login, git identity auto-derives, install an agent). onInstall now returns the box's HostStatus; no new backend.

pallaoro added 30 commits July 5, 2026 20:31
…ackages

Splits the engine from the Electron shell into two workspace packages so any transport (Electron IPC today, JSON-RPC over SSH next) can drive it — the foundation for remote/SSH-reachable operation.

- @ateam/protocol: the wire contract (AteamApi, CH, DTOs, event payloads), dependency-free, shared by renderer, main, and the future server.
- @ateam/server: the whole engine (git worktrees, agent PTYs, board state machine, hooks, loops, merge queue), Electron-free. createEngine() emits abstract events instead of webContents.send; createDispatcher() exposes the 26 engine methods as handle(method, args).
- Desktop main shrinks to a shell (547->363 lines): builds the engine, forwards its events to the renderer, bridges ipcMain -> dispatcher, and keeps only the 4 client-native handlers (dialog/clipboard). ipc.ts 620->83 lines.

Engine + loop tests move with their code into @ateam/server; adds a dispatcher unit test over an in-memory bun:sqlite db. All package typechecks, the production build, and the full suite (96 pass) are green.

Behaviorally identical: handler bodies and the board state machine were relocated verbatim. Not yet exercised via a live Electron launch (no binary in this worktree).
…lients

Adds the request/response + event framing a remote client (SSH stdio, WebSocket) uses to drive the engine — over the same dispatcher and engine the desktop already runs locally.

- @ateam/protocol: RpcRequest/Response/Event frames + createRpcClient (pure, browser-safe) — correlates responses by id, fans out event notifications, rejects in-flight calls on disconnect.
- @ateam/server: serveRpc(engine, dispatcher, transport) forwards the engine's four events as notifications and answers requests via the dispatcher; returns dispose() that frees subscriptions when a client drops.

Proven with an in-memory transport pair driving the real dispatcher: request roundtrip, unknown-method rejection, event delivery triggered by a call, and silence after dispose. 100 tests pass.

The desktop stays on native Electron IPC (the right local transport, already routed through the dispatcher); JSON-RPC is the remote path. The client-side AteamApi builder over createRpcClient lands with the SSH client (Phase 4), where the client-local bits (webUtils.pathForFile) resolve in context.

shortcut: createRpcClient has no per-call timeout (onClose rejects in-flight calls); add one with the SSH transport, where a lost reply on a live socket would hang.
…nsport

The server-side piece for remote operation: run `ateam daemon` on a box to host the engine over a unix socket, and `ateam attach --stdio` as the stateless relay that `ssh host ateam attach --stdio` execs.

- transport/socket.ts: newline-delimited JSON framing over net.Socket (socketServer/ClientTransport) — the same one-object-per-line wire the PTY daemon already uses.
- cli.ts: `daemon` runs createEngine + serveRpc per connection (single stateful owner — clients come and go, the engine and its PTY sessions persist); `attach` is a dumb stdin<->socket pipe that auto-starts the daemon if absent.

Proven over a REAL unix socket: request roundtrip, an event streamed before its response, and unknown-method error — createRpcClient <-> serveRpc across socketClient/ServerTransport. 103 tests pass.

Box-deferred to Phase 4's Hetzner milestone (flagged inline in cli.ts): the daemon runs under Node with node-pty/better-sqlite3 rebuilt (better-sqlite3 can't load under Bun), the PTY daemon bundle shipped beside the bin, and this TS compiled to JS. Adds @types/node to the server package (bun-types mistypes net.Server).

shortcut: attach auto-start retries once after 500ms; harden the retry/backoff when standing the first real server.
…ive SSH

Adds the client-side transport for remote operation and validates the whole RPC wire over real SSH+tailscale to a Hetzner box.

- transport/stream.ts: newline-JSON framing over any read+write stream pair (a duplex socket, or a child's stdout+stdin). socket transports now delegate to it — one framing implementation behind both.
- transport/ssh.ts: sshClientTransport(host, remoteArgs) spawns `ssh host …`, speaks RPC over the child's stdio (stderr inherited), returns a ClientTransport + the child. Host/keys/ProxyJump stay OpenSSH's job.

Stage-A proof (manual, needs a box): from a Mac, sshClientTransport + createRpcClient drove a stub serveRpc running under node on a Hetzner box over tailscale — the request executed ON the box (ranOn=<box-hostname>), multiple calls shared one channel, and server errors propagated. ~600ms first-call RTT.

stream.test.ts covers the separate read/write stream case (the SSH shape) via PassThrough pipes. 104 tests pass.

shortcut: no keepalive/reconnect on the ssh child yet — add ServerAliveInterval + auto-reconnect with the connection manager (Phase 5).

board box footprint fully cleaned; no changes left on the server.
createEngine opened the db without ensuring its parent dir exists. Electron's userData always does, so the desktop never hit this — but a fresh server's ~/.ateam does not, and better-sqlite3 won't create parent dirs, so `ateam daemon` crashed on first boot. mkdir -p the data dir first.

Surfaced running the real daemon live on a Hetzner box (Phase 4 Stage B), where the full loop then worked end to end: register project, create a git worktree, spawn a real claude agent, stream its terminal to the client, drive it with keystrokes, and reattach to the live session after a full disconnect — all over SSH+tailscale.

Verified: 104 tests pass, typecheck + build green.
…PC client

The client mirror of the desktop preload's window.ateam, but over any
transport (SSH stdio, socket, WebSocket) instead of Electron IPC: every
request becomes rpc.call(CH.x); every push event (taskUpdated/loopsUpdated/
ptyData/ptyExit) an rpc.on(...). Returns a total AteamApi by taking a
NativeClientApi adapter for the client-local methods no remote engine can
serve (native dialogs, clipboard staging, webUtils pathForFile).

Lives in @ateam/protocol so any client imports it without node/electron;
the package stays dependency-free. Integration-tested through a live
serveRpc/dispatcher over the in-memory stream pair.
Two client-native desktop features re-homed as server-side RPC so a remote
client can drive them on the *engine's* machine, not its own:

- fs:listDir(path?) — browse the engine's filesystem for the repo picker
  (subdirectories only, each flagged when it holds a .git; follows symlinked
  dirs, skips broken links). Over SSH a native folder dialog would browse the
  wrong box; this browses where the repos actually live.
- util:writeImageBytes(base64, ext?) — write an attached/pasted image to a
  temp file under dataDir/attachments and return its path. A headless server
  has no GUI clipboard, so the image is handed to the agent as a file path
  instead of a bitmap. Extension is sanitized (no path/separator injection);
  the engine prunes attachments older than a week on startup so they never
  accumulate unboundedly.

Surfaced on AteamApi (fs.listDir, utils.writeImageBytes), bound in
buildAteamApi over RPC, and implemented in the desktop preload so local mode
carries them identically. Dispatcher handlers unit-tested over a real db.
…ords

The client-side registry of remote hosts to drive an engine on:

- hosts table (@ateam/db), keyed by ~/.ssh/config alias (its natural PK):
  server_version, agents_available (json), last_seen. Capability metadata
  ONLY — no board mirror; the connections list renders from this cache
  without N live SSH connections, and a host's full board loads live when
  opened. Client-only; the engine never reads it. repo CRUD:
  upsertHost/listHosts/getHost/deleteHost, with partial upsert so a bare
  touch never wipes cached fields.

- connections.ts (@ateam/server, beside sshClientTransport): readSshHosts
  parses Host aliases + HostName from ssh_config (minimal — OpenSSH owns the
  full semantics at connect time; patterns skipped); listConnections
  outer-merges config hosts with saved records (flags inSshConfig/known,
  sorts by recency); recordConnection stamps last_seen on connect. Not on
  AteamApi — managing connections is a client concern about choosing an
  engine, not something a remote engine serves.

~/.ssh/config stays the source of connection truth (keys/jumphosts/hostnames
= OpenSSH's job); we persist only Ateam's own metadata keyed by alias.
…ERSION

The compatibility gate for remote connections. A client opens a transport and
calls serverHandshake(rpc) FIRST, checking the engine's protocolVersion before
trusting the rest of the surface — so a version-skewed remote fails cleanly at
the handshake instead of cryptically mid-call (a newer client hitting an older
daemon's missing method throws 'Unknown method'; a changed DTO shape corrupts
silently).

- PROTOCOL_VERSION (monotonic int in @ateam/protocol, the wire-contract pkg;
  bump on any breaking CH/args/DTO change). Deliberately not the npm version —
  workspaces are 0.0.0 and the daemon is esbuild-bundled, so package.json is
  neither meaningful nor readable at runtime.
- CH.systemHello dispatcher handler returns { protocolVersion, agents },
  reusing listAgents() for the box's installed agents.
- serverHandshake(rpc) client helper; a low-level connect primitive,
  deliberately NOT on AteamApi. Feeds recordConnection's cached version/agents.

Mirrors the initialize/protocolVersion handshake this repo already speaks in
board-mcp.ts (and the MCP/LSP norm). Tested end-to-end over the stream transport.
The daemon is Electron-free (node + node-pty + a headless xterm), and the
server owns the rest of the PTY subsystem (pty-client.ts) — so for the server
to ship a standalone `ateam` dist it must own the daemon source too, not reach
into the desktop app. Moves apps/desktop/src/daemon/index.ts →
packages/server/src/pty/daemon.ts (single source of truth; no TS import sites,
only a build-input path + a runtime path in cli.ts).

node-pty/@xterm/headless/@xterm/addon-serialize are added to @ateam/server AND
kept as desktop deps: the desktop's bundled daemon.js still requires them at
runtime and electron-rebuild must still see node-pty, so its native-module
resolution is unchanged. The desktop's electron.vite input repoints to the new
path. Verified: server typecheck + 80 tests, desktop typecheck + build (daemon.js
253kB, node-pty externalized). Residual: electron-rebuild ABI + live Electron
runtime need a real desktop run (unchanged by design; only the source moved).
Retires the dist/runtime shortcut in cli.ts and replaces Phase-4 hand-bundling
with a repeatable target: `bun run build` → dist/{cli.js, daemon.js,
package.json}. Reuses bun's bundler (no new dep); CJS output (simple-git's
@kwsites/file-exists does a bare require that breaks under bundled ESM).

better-sqlite3 + node-pty are externalized — the only two native modules; the
box installs them for its own arch via the emitted dist/package.json (node-pty
aliased to the @homebridge prebuilt fork, better-sqlite3 via node prebuilds, so
no compiler is needed there). Everything else is bundled in. Two single-entry
passes so cli.js + daemon.js land flat (cli.ts resolves daemon.js beside it).
Verified: both externalize their native module, both node --check valid.
Four bugs the first real over-SSH install surfaced (all in the attach relay /
daemon boot), each caught by driving the box end-to-end:

- import.meta.url is INLINED by the bundler to the BUILD-TIME source path, so
  the daemon paths (PTY daemon location, and the daemon the relay spawns) pointed
  at the build host's filesystem — nonexistent on the box. Derive them from the
  running script's own path (process.argv[1], realpath-resolved) instead. This
  was why the PTY daemon 'did not become reachable'.
- attach only auto-started the daemon on ECONNREFUSED, but a fresh box has no
  socket file at all → ENOENT. Handle both, and poll with backoff (daemon
  cold-start time varies) instead of a single fixed wait.
- the socket 'close' handler (exit 0) was registered before connecting, so it
  fired on every FAILED connect — right after the ENOENT that schedules a retry
  — killing attach before the retry ran (it had already spawned an orphan
  daemon). Register close-ends-relay only AFTER a successful connect.
- runDaemon awaited connectPty() before listening, so the RPC socket was blocked
  behind the PTY connect timeout; serve RPC immediately and connect PTY in the
  background (agent spawning reconnects lazily).

Also route the auto-started (detached) daemon's output to ~/.ateam/daemon.log —
a detached daemon with no logs is undebuggable on a remote box. Proven on a
fresh box: one SSH attach cold-starts both daemons, handshake + a real RPC call
succeed, and both daemons persist across disconnect.
install-remote.sh codifies the proven remote setup: build the dist, find node
22 on the box, copy dist, npm-install the two native modules (prebuilds, no
compiler), drop an `ateam` launcher on the login PATH (pins node 22 for the
native ABI), and verify the handshake. One command stands up a working remote
engine — proven end-to-end on the Hetzner box (agents:["claude"] reported).

The launcher is invoked as `bash -lc 'exec ateam attach --stdio'`: a login
shell so the daemon's PATH resolves agent CLIs (else the handshake reports no
agents), and node 22 so better-sqlite3/node-pty load. Files are only created or
overwritten, never removed.

allowHalfOpen on the RPC server: a one-shot client that sends a request then
closes its write side (EOF) must still get the reply — without it the socket's
read-end 'end' auto-closes the write-end and drops the response. Persistent
clients (the desktop) are unaffected.
The Electron main process can now drive either the in-process local engine
or a remote engine reached over SSH, chosen at runtime — the renderer and the
core preload surface are untouched.

- backend.ts: `Backend` = one swappable engine ({kind,methods,handle,on,dispose});
  localBackend = dispatcher + engine.on, remoteBackend = rpc.call/rpc.on. A stable
  `Router` is what registerIpc binds against once, routing to the active backend so
  a connection swap never re-registers ipcMain channels.
- host.ts: createHost owns the active backend, rebinds the 4 forwarded events on
  swap, and connect(alias) opens sshClientTransport → handshake (20s timeout) →
  PROTOCOL_VERSION gate → recordConnection → swap; connect(null) returns to local.
  registerHostIpc wires host:list/connect/current + pushes evt:host:changed.
- shared/host.ts: HOST_CH + HostStatus + AteamHost (protocol-only deps), added to
  both desktop tsconfigs; preload exposes window.ateamHost; global.d.ts declares it.
- ConnectionDTO graduated @ateam/server → @ateam/protocol: a pure boundary DTO the
  renderer must read without pulling server/node types into its web tsconfig.
  SshHost/ConnectionRecord stay server-internal; additive, no PROTOCOL_VERSION bump.

Renderer screens (connections UI, remote dir-browser, image-attach branch) land
next — they need a live display to smoke.
…to-create

# Conflicts:
#	apps/desktop/src/main/index.ts
#	apps/desktop/src/main/ipc.ts
…ale/TCP

A connection now records how the user wants to reach its box, and the client
opens the matching transport. Both feed the identical createRpcClient/
buildAteamApi — it's one pluggable ClientTransport seam, not two codepaths.

- cli.ts: opt-in daemon TCP listener (ATEAM_TCP_HOST/ATEAM_TCP_PORT), reusing the
  same onConnection/socketServerTransport. Refuses a wildcard/0.0.0.0 bind — this
  socket trusts the network (a Tailscale ACL), not a per-connection secret, so
  exposing it publicly would hand out an unauthenticated engine.
- db + protocol: hosts table, ConnectionDTO and ConnectionRecord gain `transport`
  ("ssh" | "tcp") and `endpoint` (host:port for tcp; null for ssh). Bootstrap
  CREATE + idempotent ALTER migration. For a tcp host we DO store the endpoint —
  there's no ssh_config to own it.
- connections.ts: listConnections maps them (ssh_config hosts → ssh; saved-only
  records → their stored transport); recordConnection persists them.
- host.ts: connect() looks up the connection and openTransport() branches — ssh
  via sshClientTransport(attach relay), tcp via socketClientTransport(net.connect).

Rationale (two /scalable passes): a WebSocket transport forces bespoke auth
(breaks "no login, no cloud") for no scaling gain; and no React Native SSH library
exposes a raw streaming exec channel (only buffered execute() or a PTY shell), so
SSH-on-mobile needs a native module. Raw TCP over Tailscale reuses off-the-shelf
primitives on both ends with Tailscale (WireGuard) as the auth boundary.

Tests: buildAteamApi over a real TCP socket; a tcp-host connection-manager case.
…to-create

# Conflicts:
#	apps/desktop/src/main/host.ts
#	packages/db/src/bootstrap.ts
#	packages/db/src/schema.ts
#	packages/protocol/src/index.ts
#	packages/server/src/cli.ts
#	packages/server/src/connections.ts
#	packages/server/test/connections.test.ts
… always SSH

A connection is a single SSH target (user@host + key) whose host can be a
Tailscale IP; Tailscale is reachability, not a separate transport. Undoes the
prior commit's transport=ssh|tcp field + endpoint, the daemon's opt-in TCP
listener, and host.ts's TCP branch — a remote client connects only via the
`attach` relay over OpenSSH, and the tailnet IP lives in the SSH host itself
(ssh_config HostName), so nothing changes from SSH's perspective.
A React Native client scaffold (Expo SDK 52 / RN 0.76, pinned for Xcode 16.2 —
SDK 57 needs Swift 6.2) with a board + connection screen, running in the iOS
simulator. Dark Ateam identity applying the clawnify DESIGN-apps structural
signature (eyebrow-labeled zones, chips for facts vs tinted badges for signals,
monochrome chrome, borders not shadows, no emoji) — not its brand theme.

The connection header is a single SSH target (pallaoro@<host>) — the host is
just an editable IP you point at the box's Tailscale address. Mock data; live
SSH wiring (buildAteamApi over a native-SSH ClientTransport) is next.

Excluded from the bun workspace (own npm/Metro toolchain): root package.json
globs !apps/mobile, and apps/mobile/metro.config.js pins module resolution.
Adds an "add connection" screen and board↔connection nav. The connection is one
SSH target modeled on Termius's host form — Label · IP/Hostname · Port · Username ·
SSH Key — where the host is just the box's Tailscale IP (no transport toggle;
always SSH). Same Ateam dark identity + clawnify structural signature as the board
(eyebrow zones, grouped rows with hairline dividers, key as a chip, one teal
primary action). First run opens on the form; Connect → board. Still mock data.
The rounded-card-plus-colored-left-border combo is an AI-slop tell (accent rail on
a rounded card) and the rail was redundant — the column color already lives in the
section eyebrow tick and the status badge. Cards are now uniform rounded cards with
a hairline border; color reads as signal, not ornament. Also default the app to the
board view (home), with the connection form reachable from the connection pill.
Replace the placeholder "A" monogram + improvised teal with Ateam's actual brand,
pulled from the desktop app:
- Logo: the "mission-control tiling" mark from apps/desktop/build/icon.svg (a wide
  top pane + two dimming squares), redrawn with Views — scales + themes, no native
  SVG dependency.
- Theme tokens from apps/desktop/src/renderer/src/index.css: #0c0c0e canvas,
  #7c5cff purple accent, ink/#e6e6ea text, amber/blue/green status. The primary
  action is ink/white (a hue is never the CTA), matching the desktop.
- Accent (purple) rationed to In Progress + the SSH-key chip; status colors carry
  needs-you/review/done. No teal anywhere.
connect-cli.ts: a headless terminal-only client — the CLI counterpart to
the desktop app. Opens the same transport the desktop uses (ssh <alias>
attach --stdio → JSON-RPC), handshakes with a version gate, dumps the live
board, spawns a raw login shell in a task's worktree on the box, and
bridges the local TTY: raw stdin → pty.write, pty.onData → stdout, snapshot
replay with seq-dedupe, resize sync, Ctrl-] detach.

Imports the transport by module path, not the @ateam/server barrel, so it
stays free of the engine's native modules and runs under bun.
A second `ateam daemon` blindly unlink()'d the live socket then listen()'d
— on the false premise that a live daemon would trip EADDRINUSE — silently
supplanting the first and orphaning its engine, live PTY sessions, and
SQLite writer. Probe by connecting first: bow out if a daemon already
serves the socket; only unlink a genuinely stale file. Handle a startup
race via EADDRINUSE re-probe, and connect the PTY daemon only after we own
the socket so a bowed-out daemon leaves no stray PTY daemon.
…/Tailscale transport

React Native can't spawn `ssh` (the desktop's transport) and no RN SSH library
exposes a clean streaming channel, so the phone reaches a box the way Coder/Gitpod/
Codespaces do it: a WebSocket over Tailscale, with WireGuard as the auth boundary.
buildAteamApi + the wire contract are pure TS, so they run in RN unchanged — only
the transport is new.

- protocol/ws.ts: wsClientTransport over the platform-global WebSocket (RN/browser/
  Bun), dependency-free and DOM-lib-free. Queues frames until OPEN so the connect-
  time system:hello handshake is never dropped.
- server/transport/ws.ts + cli.ts: wsServerTransport (one JSON frame per message,
  reusing serveRpc/dispatcher) behind an OPT-IN listener (ATEAM_WS_ADDR). Off by
  default — the box stays listener-free and the desktop SSH path is unchanged; binds
  an explicit tailnet IP and REFUSES a 0.0.0.0/:: wildcard (same guard the reverted
  TCP listener used). ws bundles into the standalone dist.
- apps/mobile: real client. src/connection.ts does wsClientTransport → createRpcClient
  → serverHandshake (PROTOCOL_VERSION gate + timeout) → buildAteamApi. App.tsx board
  is live (every project's tasks, taskUpdated pushes merged in place) with real
  host/port inputs. Metro + tsconfig resolve just @ateam/protocol without reopening
  the RN-shadowing the config guards against.
- Scrub personal host/IP (hetzner-devbox / 100.72.63.61 / pallaoro) from App.tsx,
  connect-cli.ts, and the connections test fixture.

Tests: buildAteamApi over a real WebSocket (server ↔ Bun global WebSocket client),
proving the phone path incl. queue-until-open. Typechecks: protocol, server, desktop,
mobile all green. Not verified here: the Metro bundle / live simulator run.
pallaoro added 26 commits August 4, 2026 22:28
- EnvironmentPicker: anchor the popover's bottom just above the toggle and grow
  upward (height-independent as the box list grows), and cancel .menu-pop's
  leftover `top: calc(100% + 4px)` with `top:auto` — that stale top was pushing the
  popover off-screen once the inline top was dropped. Cap height + scroll.
- .comp-yolo.active:hover: keep the amber while hovering an active auto-mode toggle
  (the generic .iconbtn:hover, same specificity but later, was graying it out).
…to-create

# Conflicts:
#	apps/desktop/package.json
After `altool --upload-app`, run scripts/testflight-distribute.mjs <buildNumber> to
wait for Apple to finish processing the build, then add it to the External + Ateam
groups via the App Store Connect API (reuses the ASC key already used for upload).
Automates the manual "add build to group" click; external still needs Apple's Beta
App Review. Export compliance is auto-answered by ITSAppUsesNonExemptEncryption:false.
…to-create

# Conflicts:
#	apps/desktop/package.json
#	apps/desktop/src/renderer/src/components/EnvironmentPicker.tsx
Assigning a build to an internal TestFlight group returns 422 'Cannot add internal
group to a build' — internal groups auto-receive every processed build. Default to
External only, and skip any internal group named with a clear note.
…to-create

# Conflicts:
#	apps/mobile/scripts/testflight-distribute.mjs
…Run on picker

Reuses install.sh verbatim over a new sshExec primitive: installs the engine
(with --service), auto-derives the box's tailnet IP into ATEAM_WS_ADDR so the
iOS app can connect, streams the installer log to the picker, then connects.
New 'Create a box' flow — Ateam provisions a fresh VPS end to end so the user
never opens a provider console or manages an SSH key: generates an app-owned key,
creates the server via the Hetzner API (real per-account regions/sizes), joins
Tailscale via cloud-init, then reuses the streamed SSH installer to bring up the
engine and connect. Refuses duplicate server names; auto-suffixes the ssh_config
alias so it never clobbers a user's own Host entry; tags servers managed-by=ateam.
…to-create

# Conflicts:
#	apps/desktop/src/main/host.ts
#	apps/desktop/src/preload/index.ts
#	apps/desktop/src/renderer/src/components/EnvironmentPicker.tsx
#	apps/desktop/src/renderer/src/index.css
#	apps/desktop/src/shared/host.ts
The composer's agent dropdown is now a popover (like the environment picker):
a coding agent that's missing on the selected box gets an Install action that runs
its official installer over SSH (streamed), then surfaces the one-time OAuth login.
Create-a-box gains 'preinstall agents' checkboxes. Agent registry carries each
tool's install command + login command (claude/codex/opencode, verified).
…to-create

# Conflicts:
#	apps/desktop/src/main/host.ts
#	apps/desktop/src/preload/index.ts
#	apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx
#	apps/desktop/src/renderer/src/index.css
#	apps/desktop/src/shared/host.ts
A fresh box ran the engine but couldn't clone private repos (no gh, no GitHub
auth, no git identity) — the first task failed. install.sh now installs gh
(sudo-free user-local binary) and, because gh auth does NOT set the commit
identity, derives it from the authenticated GitHub account (gh api user →
name + noreply email) on any re-run after sign-in. Validated live on a box.
After Create-a-box connects, show a readiness checklist instead of closing blind:
engine + Tailscale done, and the two interactive steps that remain — GitHub
sign-in (gh auth login) and agent login — with a Recheck that re-probes and
auto-derives the git identity from the GitHub account once signed in. New
host.boxReadiness(alias) probes the box over SSH. Also: the agent picker's Install
button now sits inline with the agent name.
The coding agent signs in interactively on its first run inside the task
terminal, unlike gh (whose auth the clone needs up front). So the readiness
checklist lists an installed agent as done — not a 'run `claude login`' step —
and the agent picker says it signs in on first use rather than printing a login
command.
…to-create

# Conflicts:
#	apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx
…nment

The 'Run on' picker now collapses the three ways to add a box (create on Hetzner,
set up over SSH, connect a Tailscale endpoint) behind one 'Add a remote connection'
row that expands to the methods, so the list stays short; the SSH option uses the
same Server icon as the box rows instead of a stray Download glyph. The New Task
composer also remembers the last-picked environment (localStorage ateam.runOn) and
defaults to it — validated against the project's environments — so you don't re-pick
it for every task.
@pallaoro
pallaoro enabled auto-merge (squash) August 6, 2026 20:17
Preparing an existing server over SSH already ran the same install.sh (engine +
Tailscale + gh + git identity), but unlike Create-a-box it ended on the raw install
log — so an SSH-prepared box could still hit the cryptic clone error with no nudge to
run gh auth login. Extract the checklist into a shared BoxReadinessChecklist and render
it after both flows. The SSH box is selected as soon as it connects (so clicking away
can't strand the pick), then the checklist shows what's left. onInstall now returns the
box's HostStatus (surfacing the agents install() already hands back).
@pallaoro
pallaoro merged commit a600473 into main Aug 7, 2026
1 check passed
@pallaoro pallaoro changed the title desktop: group remote-connection options + remember the chosen environment desktop: environment picker — grouped add-menu, remembered selection, readiness after SSH Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant