desktop: install coding agents on a box - #108
Merged
Merged
Conversation
…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.
Build 7 uploaded to TestFlight (adds NSLocalNetworkUsageDescription + ateamgo:// deep links over build 6). ExportOptions.plist previously lived only in the gitignored build/ dir and had to be reconstructed each release — tracking it makes the app-store-connect export reproducible.
Hold multiple engines at once (this Mac + SSH boxes) and pick, per task, where it
runs — the "Run on" pill in the New Task dialog. The engine seam is wired through the
pre-existing multi-engine aggregate (routes each call to the owning engine by id),
so no protocol/version churn.
- main/host.ts: single-active swap → a Map<alias,Backend> + createAggregate as the
router; additive connect + disconnect + connected() + origins(); events forwarded
from every held engine. New provision(alias,{cloneUrl}) targets a specific box.
- renderer: unify.ts groups the merged projects into one card per repo (by git
identity), members = each engine's projectId; the composer's EnvironmentPicker
(the old connection pill, moved into the dialog) picks Local or a box.
- A box already holding the repo runs the task with no clone (aggregation surfaces it,
the iOS model); a box without it clones+registers once from the repo's git remote
(projects:clone), then reuses it. Gate is the repo's real `git remote`, not GitHub
detection. Top-right global switcher removed.
Bumps desktop 0.1.30 → 0.1.31.
…to-create # Conflicts: # apps/desktop/package.json
…to-create # Conflicts: # apps/desktop/package.json
Build 7 compiled PROTOCOL_VERSION 1; a box updated to the 0.1.32 server reports v2, so the phone's strict handshake gate (connection.ts) refuses it. Rebuild picks up v2 automatically (Metro resolves @ateam/protocol from the repo source). Build 8 uploaded to TestFlight (Delivery UUID f702d6b5). No source/feature change; version stays 1.0.0.
The New Task dialog's agent list now reflects the agents actually installed on the
selected "Run on" environment (each engine's system:hello agents, keyed by alias).
Agents the chosen box doesn't have are disabled ("(not installed)"), and switching
environment auto-drops to an agent that engine can run. Falls back to the catalog's
own availability when the environment isn't a connected engine.
- 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The composer's agent dropdown becomes a popover (like the environment picker): a coding agent 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. The agent registry now carries each tool's install + login command (claude/codex/opencode, verified via docs). The claude installer was validated live on a real box.