Skip to content

Privacy policy for Ateam & Ateam Go - #131

Merged
pallaoro merged 1 commit into
mainfrom
privacy-policy
Aug 20, 2026
Merged

Privacy policy for Ateam & Ateam Go#131
pallaoro merged 1 commit into
mainfrom
privacy-policy

Conversation

@pallaoro

@pallaoro pallaoro commented Aug 20, 2026

Copy link
Copy Markdown
Member

Adds PRIVACY.md (linked from the README) covering both the desktop app and Ateam Go, written for the App Store 5.1.1(i)/5.1.2(i) rejection of Ateam Go 1.0 (8).

Grounded in the actual apps/mobile code: no AI SDK, no analytics/telemetry/crash reporting, no payments; the app's only connection is a WebSocket to the user's own machine over their private network (src/connection.ts); on-device AsyncStorage holds only host/port, last project, and preview port (src/storage.ts); demo mode is fully offline (src/demo.ts).

Once merged, the App Store Connect privacy-policy URL is:
https://github.com/clawnify/ateam/blob/main/PRIVACY.md

…point at

No backend, no analytics, no collection — documents the local-first
architecture: the phone talks only to the user's own box over their
private network, AI agents run on the user's machine under their own
provider accounts, and the only stored data is connection settings in
on-device AsyncStorage. Written against the verified code in apps/mobile
(no AI/analytics/payment SDKs present).
@pallaoro
pallaoro merged commit 2f2cb8a into main Aug 20, 2026
1 check passed
pallaoro added a commit that referenced this pull request Aug 21, 2026
Two tidies after syncing origin/main.

docs/privacy-policy.md is deleted. #131 added PRIVACY.md, linked from the README
and now the URL App Review will follow. The old file predates it, still carried an
unfilled '[set a support/privacy contact address before publishing]' placeholder
and an internal 'To publish:' note, and nothing referenced it. Two policies in a
public repo is a hazard when one is stale — a reviewer or a user could land on
either.

The dispatcher test is de-duplicated. Squash-merging #128 and then merging
origin/main back in re-added the shell-spawn broadcast test on top of the copy
already on main: git saw both sides adding the same lines independently, so it
kept both rather than conflicting. The file now matches main exactly.
pallaoro added a commit that referenced this pull request Aug 21, 2026
* refactor(server): extract Electron-free engine + wire contract into packages

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

* feat(protocol,server): transport-agnostic JSON-RPC layer for remote clients

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.

* feat(server): ateam CLI (daemon + attach relay) over a socket RPC transport

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.

* feat(server): SSH client transport — proven driving the engine over live 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.

* fix(server): create the data dir before opening SQLite

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.

* feat(protocol): buildAteamApi — client-side AteamApi bound over the RPC 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.

* feat(protocol,server): remote-native fs:listDir + util:writeImageBytes

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.

* feat(db,server): connection manager — ssh_config hosts + per-host records

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.

* feat(protocol,server): system:hello connect handshake with PROTOCOL_VERSION

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.

* refactor(server,desktop): relocate the PTY daemon into @ateam/server

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

* build(server): bun-bundle a standalone `ateam` server dist

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.

* fix(server): make the ateam CLI work on a fresh remote box

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.

* feat(server): one-shot install-remote.sh + half-open RPC support

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.

* feat(desktop): swappable local⇄remote engine backend in the main process

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.

* feat(server,desktop): per-connection transport choice — SSH or Tailscale/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.

* revert(server,db,desktop): drop the per-connection transport choice — 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.

* feat(mobile): apps/mobile — Expo/React Native board preview

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.

* feat(mobile): connection screen — SSH host form (Termius-modeled)

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.

* fix(mobile): drop the colored left-rail on task cards

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.

* feat(mobile): use the real Ateam logo + theme

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.

* feat(server): interactive remote-terminal client over SSH

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.

* fix(server): make `ateam daemon` single-instance safe

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.

* feat(mobile,server): wire the phone to a live engine over a WebSocket/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.

* feat(mobile): live terminal — attach to a task's agent session from the phone

Tapping a task opens a real terminal on the box: if the task has a live agent
session (its Claude Code TUI running) we ATTACH and replay its screen; otherwise
we spawn a login shell. Detaching never kills the session, so the agent keeps
working and you reattach later — the "task = live agent session" model, driven
from the phone. Proven end-to-end on a physical iPhone: the full Claude Code TUI
rendered in the app, running in a worktree on the Hetzner box, over Tailscale.

- xterm.js renders inside a react-native-webview (a raw PTY stream is ANSI
  escapes — it needs a real terminal emulator). xterm + fit addon are inlined
  (scripts/gen-xterm-assets.mjs → src/xterm-assets.ts) because the webview is
  offline/CSP-restricted; terminal-html.ts assembles them + the bridge.
- TerminalScreen.tsx wires the webview ↔ PTY with the same contract as the
  desktop Terminal.tsx and connect-cli: spawnShell/listForTask, snapshot + seq
  dedupe (buffer-until-ready), onData→write, fit→resize, onExit. TUI key toolbar
  (esc / ⇧tab / / / arrows / ^C) modeled on Termius, for keys the soft keyboard
  can't send.
- App.tsx: task cards are pressable → open TerminalScreen for that task.
- Deps: react-native-webview (native module → pod install on build), @xterm/xterm
  + @xterm/addon-fit (dist inlined). Metro bundle 561 modules / 2.57MB, mobile
  typecheck clean.

* fix(mobile): repaint the TUI on terminal reattach

Reopening a task's terminal replayed the scrollback but left a running
full-screen TUI (Claude Code) missing its live input box + footer: a same-size
reattach fires no SIGWINCH, so the TUI never repaints its alt-screen UI and you
see only the serialized snapshot. After applying the snapshot, jiggle the PTY
size (rows-1 → rows) to force SIGWINCH; the TUI then repaints everything from
scratch — authoritative, and better than trusting the snapshot for alt-screen
content. Harmless on a fresh shell (no TUI to redraw).

* feat(mobile,agents): board composer, agent-mode launch, project switcher, persisted connection

Mobile board is now a control surface, not just a viewer:
- Composer (src/Composer.tsx) at the board bottom: prompt + agent picker +
  auto-mode (yolo) + agents-mode toggles + send. Submitting creates a task and
  launches the agent, then opens its terminal — mirrors the desktop's
  create→spawnAgent sequence.
- Agent mode: engine gains `agentsCommand` ("claude agents") + agentCommand({
  agentMode}); protocol spawnAgent + dispatcher pass `agentMode` through (additive
  optional, no PROTOCOL_VERSION bump). Launches the tool's multi-agent board in the
  task's worktree (the PTY already cwd's there, so no --cwd).
- Board header redesigned: connection status dot (left, tap → connection page) +
  centered project dropdown; dropped the IP/app-icon/name chrome.
- Connection page: back-to-board + Disconnect when connected; host/port persisted
  via AsyncStorage (src/storage.ts) so a restart/reinstall keeps the box IP.
- Terminal reattach: widen the SIGWINCH redraw jiggle (350/900ms, cancel on
  unmount) so a running TUI fully repaints instead of coming back partial.

Verified headless: agents/protocol/server tc + 81 server tests green, mobile tc
clean, Metro bundle 569 modules. Box redeployed via login shell so the agents
list populates (system:hello → agents:["claude"]).

* fix(mobile,agents): scope agent mode to the worktree, hide prompt in agent mode, terminal scroll + keyboard dismiss

- Agent mode is now scoped: `claude agents` ignores the process cwd and shows the
  global board, so pass `--cwd <worktree>` explicitly (dispatcher hands
  agentCommand the task's worktreePath; single-quoted for spaces). Matches the
  `claude agents --cwd ~/repo` shape.
- Composer: agent mode passes no prompt (its board is interactive), so hide the
  textarea and show a hint; agent-mode tasks get a unique time-stamped name so the
  worktree branch doesn't collide.
- Terminal: touch-drag now scrolls the xterm scrollback (xterm only scrolls on
  wheel events, absent on touch → map drag to term.scrollLines; a tap re-focuses).
  Added a "Hide ⌨" button that blurs the hidden input so iOS dismisses the
  keyboard and you can see the full terminal.

* fix(mobile): agent-mode task-name field + terminal touch scroll + keyboard refit

- Composer: agent mode still needs to name the worktree, so keep the field but
  relabel it "TASK NAME" (placeholder makes clear it's a name, not a prompt); its
  value is passed as the explicit task name (ComposerSubmit.name), while normal
  mode still derives the name from the prompt.
- Terminal touch scroll: handle drags at the document level with capture +
  preventDefault so xterm's focused hidden textarea can't swallow the gesture (the
  previous #term-level passive handler didn't fire). A tap still re-focuses.
- Terminal fit: refit on keyboard show/hide (RN Keyboard events → __termFit) so
  the TUI is always sized to the visible area and its input box isn't clipped
  under the keyboard — the real fix for "see the full terminal".

* spike(mobile): native SwiftTerm terminal — SPM-in-Expo risk retired

The load-bearing risk from the /scalable ruling was whether SwiftTerm (SPM-only)
could be linked into this SDK-52 / old-arch / CocoaPods Expo app. It can: vendor
SwiftTerm's iOS sources (53 swift + 1 metal, Mac/AppKit dir dropped) into an Expo
local module and let CocoaPods compile them in-module. Build succeeded, 0 errors,
installed on the device.

- modules/expo-swiftterm: Expo local module (autolinked, no pbxproj surgery).
  ExpoSwifttermView wraps SwiftTerm's UIKit TerminalView (a UIScrollView → native
  scroll/selection/copy). TerminalViewDelegate.send → onInput event; clipboardCopy
  → UIPasteboard; feed(text:) exposed as a view AsyncFunction (imperative, so no
  streamed chunk is dropped by prop-diffing).
- src/NativeTerminalScreen.tsx: same PTY contract as the webview terminal
  (attach/spawn, snapshot+seq, onData→feed, input→write, size→resize) but rendering
  the native view. First onSizeChange is the "ready" signal (snapshot + flush).
- App.tsx: USE_NATIVE_TERMINAL flag switches to it; flip to false to fall back to
  the xterm-webview. Static banner feeds in the native init to prove render even
  before the stream is judged on-device.

* fix(mobile): native terminal render (color+layout) + agent brand logos

- SwiftTerm view was black: it fed the banner into a 0×0 grid (init frame .zero)
  and left the palette at defaults. Give the TerminalView a real initial frame,
  set an explicit dark theme (black bg / light fg), and feed only once the view
  has a non-zero layout. (feed-from-JS via the view function is still being judged
  on-device; the static banner proves render independently.)
- Agent logos: port the desktop's AgentIcon SVG paths (Claude #D97757, Codex,
  OpenCode) to react-native-svg; show them on task-card agent tags and in the
  composer's agent picker instead of initials.

* fix(mobile): render SwiftTerm via Auto Layout so the grid sizes (was black)

The black screen was the ExpoView's own background: the nested TerminalView had
its frame hand-set, which doesn't reliably fire the terminal's own layoutSubviews
— and that's what calls processSizeChange to compute the grid + render. Pin the
terminal with Auto Layout constraints (edge-to-edge) so its layout fires with real
bounds, and seed the banner via DispatchQueue.main.async (mirrors SwiftTerm's own
SwiftUI wrapper).

* fix(mobile): native SwiftTerm view now registers + renders (three real bugs)

The black screen was never a SwiftTerm render problem — RN couldn't find the
native component at all. The simulator (observing directly instead of guessing
across device rebuilds) revealed "No component found for ViewManagerAdapter_
ExpoSwiftterm", and three chained root causes:

1. Provider filter by deployment target: the module podspec declared iOS 16.4 but
   the app targets 15.1, so Expo autolinking's `supports_platform?` silently
   dropped it from the generated ExpoModulesProvider → never registered. Match the
   app target (SwiftTerm supports iOS 13+).
2. Missing vendored source: dropping the whole Mac/ dir also dropped the SHARED
   `AccessibilityService` (defined in Mac/ but unguarded, used by iOS) → compile
   error. Re-vendor Mac/ (its macOS-only files are `#if os(macOS)` no-ops on iOS).
3. Stale provider: `expo run:ios` skips pod install when the Podfile is unchanged,
   so adding a local module didn't regenerate the provider — force pod install.

Verified in the iOS Simulator: the native TerminalView renders (green ANSI banner,
computed a real 52×54 grid, correct 402×814 bounds). Diagnostics + the debug
early-return removed; the view is clean (black, edge-pinned via Auto Layout).

* feat(mobile): native terminal keyboard control (dismiss + avoid overlap)

SwiftTerm has no keyboard-avoidance of its own, so the keyboard overlapped the
terminal with no way to dismiss it. Fixes:
- Native blurKeyboard/focusKeyboard (resign/becomeFirstResponder) exposed as view
  functions; the JS handle + a "Hide ⌨" header button drive them.
- Wrap NativeTerminalScreen in KeyboardAvoidingView so the terminal shrinks above
  the keyboard (SwiftTerm's sizeChanged delegate then resizes the remote PTY).

Known SwiftTerm rough edges still open (its own "Selection/Accessibility lags"
caveat): re-selecting text after one selection is flaky. Tracked for follow-up.

* feat(mobile): fix terminal connection hang + native-terminal UX polish

Root-caused the "resolving session… never resolves" hang: the client code is
correct (proven — buildAteamApi over wsClientTransport resolves in ~28ms, and the
whole native terminal works live in the simulator). The device hang is a WS over
Tailscale going half-open on NAT/WireGuard idle with no close event, and the RPC
client has no per-call timeout → infinite wait.

- connection.ts: 15s keepalive (periodic system:hello) keeps outbound traffic
  flowing so the NAT/WireGuard mapping stays live; cleared on close. Uses an
  existing method — no box change.
- NativeTerminalScreen: per-call timeout on the resolve so any residual stall
  surfaces as an error + "Back to board" instead of a spinner forever. Plus our
  own shortcut toolbar (esc/⇧tab/arrows/^C — SwiftTerm's accessory bar is now off),
  a back-chevron icon button (was "‹ Board" text), white accent (not purple), and
  a KeyboardAvoidingView so the keyboard no longer overlaps.
- ExpoSwiftterm: inputAccessoryView = nil (plain iPhone keyboard); native
  blurKeyboard/focusKeyboard exposed for the Hide-keyboard + shortcut buttons.
- Composer: agent picker collapses to the selected agent + a tap-to-open popover;
  extra bottom padding so the mode buttons clear the iOS home indicator.

* feat(mobile): PgUp/PgDn in the native terminal toolbar — drive the TUI's own scroll

SwiftTerm scrolls the emulator's own scrollback (empty on an alt-screen), and on a
mouse-mode TUI it forwards touch-drags, not wheel events — so neither scrolls a
full-screen agent like Claude Code. Claude scrolls its conversation on PageUp/PageDown
in every mode, so surface those keys. Natural drag-to-scroll (sending the same keys)
is the follow-up once this confirms the mechanism.

* feat(mobile): drag-to-scroll in the native terminal (finger → PageUp/PageDown)

PageUp/PageDown confirmed to scroll Claude Code, so wire a vertical finger drag to
the same keys. Vendored SwiftTerm patch (marked ATEAM PATCH, re-apply on bump): its
mouse-mode pan gesture (active only for TUIs that enable mouse mode, e.g. Claude)
now maps vertical drag distance to PageUp/PageDown instead of forwarding a mouse-
drag (which a full-screen agent doesn't treat as scroll). Shell scrollback still
scrolls via the separate selection pan (mouse mode off). Page-granular; `step`
tunable for feel.

Also: PgUp/PgDn toolbar buttons no longer refocus the keyboard — scrolling is
reading, not typing (send() skips focusKeyboard for scroll keys).

* tune(mobile): more responsive drag-scroll (step ~1/14 view height per page)

Was 1/5 of the view height per PageUp — too much thumb travel for little scroll.
Lowered to ~1/14 so a short flick pages the conversation.

* feat(mobile): auto-reconnect on launch + toolbar/composer padding

- Auto-reconnect: on launch, if a box is saved, prefill AND connect to it so
  reopening the app lands straight on the live board (falls back to the connection
  screen with the error on failure). Extracted the one connect path into connectTo,
  used by both the Connect button and the launch auto-connect.
- Terminal shortcut toolbar: dropped maxHeight (it clipped) + extra bottom padding
  so the key row clears the iOS home indicator; horizontal scroll already handles
  overflow when the keys don't fit.
- Composer: a bit more bottom padding (34 → 42).

* feat(mobile): drop bottom padding when keyboard is up + terminal ⏎ key, PgUp/PgDn to the end

- useKeyboardVisible hook: Composer + terminal toolbar drop their home-indicator
  bottom padding while the keyboard is on screen (KeyboardAvoidingView already lifts
  them, and the keyboard covers that area) — no dead space above the keyboard.
- Terminal shortcut bar: add ⏎ (Enter), and move PgUp/PgDn to the end.

* feat(mobile): tap output → dismiss keyboard (cursor-relative, agent-agnostic)

Tapping the terminal's output/history now hides the keyboard; tapping the input
area brings it up. The "input area" is inferred from the terminal CURSOR (the
universal active-input location for any agent — Claude/Codex/OpenCode all keep
their cursor in their input), not a hardcoded per-agent layout. Vendored SwiftTerm
singleTap patch (marked ATEAM PATCH): near the cursor row → become first responder,
far from it → resign. Reuses SwiftTerm's existing calculateTapHit + displayBuffer
cursor position.

* feat(mobile): add a project by browsing the box's folders from the dropdown

The project dropdown now has an "Add project…" entry that opens a folder browser
over the box's filesystem — reusing fs.listDir (subfolders, each flagged if it holds
a .git) and projects.register, the same remote folder-pick the desktop uses. Starts
at the box's home dir, navigable (into subfolders + up); tap "Add" on a git repo to
register it. On register: refresh the board and select the new project.

* fix(mobile): project browser as a view-swap, not a nested modal (froze the app)

Opening the browser Modal while the project-dropdown Modal dismissed in the same
tick deadlocked iOS (can't transition two modals at once) → the app locked up.
Render the browser as a full-screen view-swap instead (like the terminal screen);
no nested modals.

* fix(mobile): repaint the native terminal after keyboard resize (agents mode)

The terminal grows/shrinks with the keyboard, but a full-screen TUI (Claude agents)
didn't always repaint cleanly after the rapid resize animation — leaving stale
height. On keyboardDidShow/Hide, once settled, jiggle the PTY size (rows-1 → rows)
to force a SIGWINCH full redraw at the final dimensions (same trick as the reattach
fix). Tracks the latest cols/rows from onSizeChange.

* feat(desktop): connection switcher — drive a remote box over SSH/Tailscale from the UI

The backend engine-seam (window.ateamHost: list/connect/current) shipped earlier
but had no faucet in the renderer. Add a topbar switcher: pick This Mac (local) or
any ~/.ssh/config host; shows the active engine + a live status dot, connecting
spinner, and inline connect errors (unreachable / daemon down / protocol mismatch).
On switch, every window drops its selection and reloads projects/tasks/agents from
the newly-active engine. Empty state points first-time users at ~/.ssh/config.

* docs: guide for running Ateam online (agents on a remote box over SSH/Tailscale)

* chore: release 0.1.29

* chore(mobile): prep for TestFlight — app name, iOS build number, export-compliance + publishing guide

- app.json is the source of truth (ios/ is prebuild-generated + gitignored):
  name mobile→Ateam, slug→ateam, add ios.buildNumber (bump per upload), and
  ITSAppUsesNonExemptEncryption=false (standard TLS/SSH/WS only) to skip the
  export-compliance prompt on every upload.
- docs/publishing-ios.md: internal-TestFlight-first pipeline, signing/App Store
  Connect setup, local-archive + EAS routes, and the review demo-box path for
  going wider (keeps local-first).

* chore(mobile): bundle id → com.clawnify.ateam (Clawnify namespace)

* chore(mobile): app name → Ateam Go (match App Store listing + icon)

* feat(mobile): app icon from the Ateam brand (full-bleed, iOS-safe, no alpha)

Adapt the desktop mission-control mark for iOS: full-bleed dark gradient + board
tiles, no macOS squircle/shadow (iOS applies its own mask), flattened opaque so it
passes App Store icon rules. Source kept as assets/icon.svg for future edits.

* feat(mobile): upgrade to Expo SDK 54 (RN 0.81) for Xcode 26 / iOS 26 SDK

Apple now requires builds made with the iOS 26 SDK (Xcode 26); SDK 52 can't produce
those. Upgrade to SDK 54 — the last SDK supporting the legacy architecture, so the
vendored SwiftTerm native view needs no Fabric migration (newArchEnabled pinned false).

- expo ^54, react-native 0.81.5, react 19.1.0 (expo install --fix)
- .npmrc legacy-peer-deps=true (npm can't resolve the react 18→19 transition otherwise)
- plugins/withFmtConstevalFix.js: build the fmt pod as C++17 — RN 0.81 vendors fmt
  11.0.2, whose consteval format-string checks fail under Xcode 26's Clang (fixed in
  fmt 12 upstream, not backported to 0.81). Durable config plugin (ios/ is regenerated).
- metro.config: drop disableHierarchicalLookup — SDK 54's expo pulls in nested
  transitive deps (whatwg-url-without-unicode → webidl-conversions) that the walk-up
  must resolve; bun-root shadowing is already prevented by the workspace exclusion.
- gitignore build/ (archive/export artifacts)

Verified: archive + export signed Apple Distribution (X2VZX44YM2), uploaded to
TestFlight (UUID 5f44c209). Xcode 26 also needs one-time: -downloadPlatform iOS +
-downloadComponent MetalToolchain.

* docs(mobile): update iOS publishing guide for Xcode 26 / SDK 54 (platform+metal downloads, proven build recipe)

* feat(mobile): auto-reattach to the box on foreground / after a drop

The daemon already keeps agents + PTYs alive across disconnects, but the mobile
client held a single WS with no reconnect path — backgrounding iOS or a network
flip left a dead socket and a frozen board. Add client-side reattach:

- connection.ts: surface an unexpected socket drop via connect(url,{onClose}) —
  distinguished from an app-initiated close(); add ping() (timed handshake) to
  tell a live socket from a half-open one.
- App.tsx: remember the connect target as intent; reattach on socket drop and on
  AppState 'active' (probe first, only reconnect if dead), with capped backoff
  (1s->15s). connGen bumps per (re)connect so the board's event stream and any
  open terminal rebind to the fresh api (terminal remounts + re-resolves its
  still-alive PTY). Explicit Disconnect clears the intent so it stays down.
- app.json: bump ios.buildNumber to 2.

Reuses RN-core AppState + the transport's existing onClose — no new native deps.
Verified: typecheck, lint, expo export (713 modules). On-device behaviour pending.

* feat(mobile): remember the last-selected project across launches

Reopening the app dropped you on project #1 every time. Persist the picked
project and restore it on launch:

- storage.ts: save/loadSelectedProject (AsyncStorage), mirroring the connection.
- App.tsx: persist on every explicit pick (dropdown + after registering a new
  project); load the saved id BEFORE the first connect so refresh() prefers it,
  with a self-correcting fallback to project #1 if it no longer exists on the box.

Typecheck + lint clean.

* feat(mobile): open the box's dev server in the phone browser (#73)

A one-tap preview of what the agent is building. No tunnel or port-forward: the
phone is already on the tailnet, so the box's dev server is directly reachable at
the same host we connected to.

- App.tsx: a ↗ button in the board header opens a modal showing http://<box>:<port>
  with an editable port (default 3000, remembered); Open → Linking.openURL. Host is
  target.current.host — the connected box's Tailscale address, filled automatically;
  disabled when not connected.
- storage.ts: load/savePreviewPort.

RN-core Linking, no new deps. Typecheck + lint + expo export clean.

* feat(mobile): attach a photo/screenshot to the agent (#72)

A +img button leads the terminal's key bar. Pick an image → stage its bytes on
the box via util.writeImageBytes → TYPE the returned path into the PTY. Typed
keystrokes (not a paste) are what trigger the agent's path→[Image #N] detection —
same mechanism (and same escapePath) the desktop terminal uses on a file drop.

- NativeTerminalScreen.tsx: +img key, attachImage handler, escapePath/extFromAsset.
- app.json: expo-image-picker plugin + photo-library permission string.
- deps: expo-image-picker ~17.0.11.
- bump ios.buildNumber to 3.

Reuses the existing writeImageBytes RPC — no server/protocol change. Typecheck +
lint + expo export clean; on-device attach path pending.

* refine(mobile): tmp-dir image staging, paperclip icon, neutral attach button

Follow-ups on the image-attach feature:
- server: writeImageBytes stages under <os-tmpdir>/ateam-attachments (like Termius)
  instead of <userDataDir>/attachments — transient, OS-cleaned, still pruned by the
  engine on startup. Applies to every client (desktop included); agent readability
  unchanged. Test + prune updated to match.
- mobile: the terminal attach button now shows a Feather paperclip icon (bundled,
  no new dep) instead of the +img text, styled like the other keys (no accent).
- bump ios.buildNumber to 4.

* feat(mobile): center content on iPad (cap width, keep phone layout intact)

supportsTablet was already on, so the app installs on iPad — but the phone layout
stretched edge-to-edge. Cap the board, connection form, and composer to a centered
CONTENT_MAX (720) column; the terminal stays full-width (more columns is better).
No-op on iPhone (720 > any screen), a centered column on iPad. No new deps.

Typecheck + lint + expo export clean.

* chore(mobile): bump ios.buildNumber to 5 (iPad centering build)

* feat(mobile): built-in demo mode (offline canned board + terminal) for App Review

Ateam is local-first with no shared backend a reviewer could sign into, so App Store
guideline 2.1 points to a built-in demo instead of demo credentials. Add a 'Try the
demo — no box needed' button on the connection screen that enters a fully offline
Connection: canned projects/tasks/agents and a realistic Claude Code terminal.

- src/demo.ts: fakes the RPC *transport* (answers the channels the app calls with
  canned DTOs) fed through the real createRpcClient + buildAteamApi, so every screen
  renders unchanged. No UI fork. Demo never reattaches and persists nothing.
- connection.ts: export mobileNative so demo reuses the same native stub.
- App.tsx: startDemo entry + the connection-screen button.
- bump ios.buildNumber to 6.

Also unblocks deterministic App Store screenshots. Typecheck + lint + expo export clean.

* docs(appstore): App Store submission package for Ateam Go

Privacy policy, App Review notes (demo-mode / guideline 2.1), listing copy
(name/subtitle/description/keywords/age-rating/App-Privacy answers), and the
publishing checklist + screenshot spec/pipeline. Text-only prep for the public
listing; internal TestFlight remains review-free.

* docs(readme): reword intro and requirements copy

* Readme update

* feat(desktop): multi-engine aggregation core for per-task environments

Groundwork for tagging each task with its environment (This Mac / a box) instead of
the global connection switch. A pure, Electron-free router that holds several backends
at once and: merges collection reads (projects/agents/loops), routes entity calls
(tasks/git/pty) to the owning engine via a learned id->backend map (all ids are
globally-unique UUIDs, so no namespacing), and falls back to local for un-routable
calls. Environment = the owning backend; no new task state or migration.

Unwired — host.ts keeps its single-active path untouched; this is the proven core.
7 unit tests (merge, route-by-project/terminal, create+learn, fallback); desktop
typecheck excludes *.test.ts (tests run via bun test, keeping bun types out of the
Electron-main tsconfig).

* docs: move internal App Store/publishing docs out of the public repo

The repo is public; these were internal operational runbooks. Remove the iOS
publishing runbook (it exposed the Apple Team ID + ASC Key ID — identifiers, not
the secret .p8, which was never committed) and the appstore/ package (checklist,
review notes, listing strategy) — their content now lives in Clawnify knowledge.
Keep the genuinely public docs: the online-ateam user guide and the privacy policy
(moved to docs/privacy-policy.md; personal email replaced with a contact placeholder).

Note: git history still contains the removed identifiers; rotate the ASC key if
that matters (the private key itself was never exposed).

* feat(mobile): add local-network permission + ateamgo:// deep links

- NSLocalNetworkUsageDescription: connecting to a box over a LAN IP trips iOS's
  local-network privacy; without the string users get a generic prompt or a silent
  failure. Surfaced by a pre-submission App Review self-audit.
- ateamgo:// URL scheme + a Linking handler: ateamgo://demo enters the offline demo,
  a trailing /task/<id> opens that task's terminal, /preview opens the preview modal.
  Primarily for driving screens for App Store screenshots, but a real deep-link primitive.

Goes in the next TestFlight build (buildNumber bump deferred to that build).

* chore(mobile): bump iOS build 6→7, check in ExportOptions.plist

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.

* chore: release 0.1.30

* Desktop: per-task remote environments (run a task on a connected box)

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.

* chore(mobile): iOS build 7→8 — rebuild for PROTOCOL_VERSION 2

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.

* Desktop 0.1.33: env-aware agent picker in the composer

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.

* Desktop 0.1.34: fix Run-on popover position + auto-mode hover state

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

* mobile: script to auto-distribute a TestFlight build to beta groups

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.

* mobile: distribute script skips internal groups (they auto-distribute)

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.

* ci: run typecheck, lint, and tests on PRs and main

* ci: fix root typecheck (per-project tsc, no build graph); gate on typecheck + tests

* ci: set a git identity so git-core initRepository tests can commit

* desktop: one-click box provisioning — Set up a box over SSH from the 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.

* desktop: create a box from a cloud provider (Hetzner)

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.

* desktop: install coding agents on a box

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

* install.sh: make a box task-ready — install gh + derive git identity

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.

* desktop: box-readiness checklist + inline agent Install

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.

* desktop: agent login isn't a box-setup pre-step

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.

* chore: release 0.1.38

* desktop: group remote-connection options + remember the chosen environment

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.

* desktop: show the box-readiness checklist after Set-up-over-SSH too

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

* chore: release 0.1.39

* docs: show the in-app Create a box flow in the README

Add the Create-a-box screenshot and a 'no box yet?' callout to the top of the
'Run your agents on a server' section, framing the in-app provisioning (region +
size → VPS + SSH key + Tailscale + engine) as the easy path above the manual recipes.

* docs: frame boxd as the first 'box provider'

Reframe the boxd recipe into a general 'Use a box provider' pattern — any service that
creates a box and writes an ssh_config alias plugs into Ateam with no integration (verified:
readSshHosts already enumerates ~/.ssh/config, boxd-aware). State the reusable recipe
(create → install.sh → gh/agent login → connect) up front, with boxd as the worked example;
future services are another short recipe. Fix the transition line to name the two DIY paths.

* feat: create a new project folder (iOS, desktop, server)

You could only register an existing repo. Now:
- server: register(path, {init:true}) creates the folder when missing before git init
  (mirrors the projectsClone handler); a dispatcher test drives it end to end.
- iOS: a 'New' button in Add project names a folder and creates+inits it on the box.
- desktop: the folder picker gets macOS's 'New Folder' button (createDirectory), and
  App.tsx already offers to git init a non-repo folder.

* chore(mobile): bump iOS buildNumber to 9

* chore: release 0.1.40

* Desktop: reconnect known boxes at launch, so remote tasks survive a restart

A box's tasks exist for the aggregate only while its engine is HELD, and a cold
start held only the local one — so the board opened with every remote task
missing until you created a remote task, whose connect put the box back into the
union and made all the others appear at once.

createHost now reconnects every box in the `hosts` registry (`known` — we've
handshaked it before), detached and in parallel: the board never waits on a box
that's asleep, each connect broadcasts its own arrival, and the renderer already
reconciles connection changes additively.

That surfaced a routing bug: projects:remoteUrl takes a projectId but was missing
from aggregate's ENTITY set, so a remote project's remote-URL lookup asked the
LOCAL engine — "Project not found: <id>" on every launch once boxes reconnect
themselves. Added it, with a regression test.

The project row now shows one icon per environment (monitor = this Mac, server =
a box) instead of the joined names, which crowded the row as soon as a repo
spanned more than one box; the name is the hover title and the accessible name.
Monitor rather than Laptop because lucide draws Laptop 15 units tall against
Server's 20, which read as mismatched at the same size.

Verified against a copy of a real userData dir: both known boxes' last_seen
updated ~17s after launch with no interaction, and the run logged no errors.

* Desktop: Mission Control listens for sessions instead of polling every task

The grid refreshed with setInterval(2500) around a SERIAL await chain — one
pty:listForTask per task, awaited in order. listForTask is routed per task by the
aggregate, so for a box-owned task each pass was an SSH round-trip: at ~20 remote
tasks and ~100ms RTT a pass could outlast its own 2.5s interval, with no
re-entrancy guard to stop passes stacking. Connecting known boxes at launch made
this worse, since the poll now leaves the machine from startup rather than after
you pick a box.

Nothing needed polling. Both spawn paths broadcast taskUpdated, a dying PTY
broadcasts ptyExit, and a box's events are forwarded to every window — so a timer
could not learn anything first. Now: fetch once, refresh on those events, and
fan the per-task fetch out with Promise.all (initial load is one RTT, not N).

Except ptySpawnShell did NOT broadcast, which the poll was quietly covering: a
shell opened in one window was invisible to every other client and to the phone
until something else touched the task. Made the broadcast symmetric with the
agent path, with a test that fails without it.

Also adds the prefers-reduced-motion block the renderer never had. Scoped: the
pulsing status dots stop (decoration — colour already carries the state), while
spinners keep spinning, since freezing them reads as a hung app.

* Cleanup: one privacy policy, and a test the main sync duplicated

Two tidies after syncing origin/main.

docs/privacy-policy.md is deleted. #131 added PRIVACY.md, linked from the README
and now the URL App Review will follow. The old file predates it, still carried an
unfilled '[set a support/privacy contact address before publishing]' placeholder
and an internal 'To publish:' note, and nothing referenced it. Two policies in a
public repo is a hazard when one is stale — a reviewer or a user could land on
either.

The dispatcher test is de-duplicated. Squash-merging #128 and then merging
origin/main back in re-added the shell-spawn broadcast test on top of the copy
already on main: git saw both sides adding the same lines independently, so it
kept both rather than conflicting. The file now matches main exactly.
pallaoro added a commit that referenced this pull request Aug 21, 2026
* refactor(server): extract Electron-free engine + wire contract into packages

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

* feat(protocol,server): transport-agnostic JSON-RPC layer for remote clients

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.

* feat(server): ateam CLI (daemon + attach relay) over a socket RPC transport

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.

* feat(server): SSH client transport — proven driving the engine over live 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.

* fix(server): create the data dir before opening SQLite

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.

* feat(protocol): buildAteamApi — client-side AteamApi bound over the RPC 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.

* feat(protocol,server): remote-native fs:listDir + util:writeImageBytes

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.

* feat(db,server): connection manager — ssh_config hosts + per-host records

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.

* feat(protocol,server): system:hello connect handshake with PROTOCOL_VERSION

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.

* refactor(server,desktop): relocate the PTY daemon into @ateam/server

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

* build(server): bun-bundle a standalone `ateam` server dist

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.

* fix(server): make the ateam CLI work on a fresh remote box

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.

* feat(server): one-shot install-remote.sh + half-open RPC support

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.

* feat(desktop): swappable local⇄remote engine backend in the main process

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.

* feat(server,desktop): per-connection transport choice — SSH or Tailscale/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.

* revert(server,db,desktop): drop the per-connection transport choice — 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.

* feat(mobile): apps/mobile — Expo/React Native board preview

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.

* feat(mobile): connection screen — SSH host form (Termius-modeled)

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.

* fix(mobile): drop the colored left-rail on task cards

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.

* feat(mobile): use the real Ateam logo + theme

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.

* feat(server): interactive remote-terminal client over SSH

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.

* fix(server): make `ateam daemon` single-instance safe

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.

* feat(mobile,server): wire the phone to a live engine over a WebSocket/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.

* feat(mobile): live terminal — attach to a task's agent session from the phone

Tapping a task opens a real terminal on the box: if the task has a live agent
session (its Claude Code TUI running) we ATTACH and replay its screen; otherwise
we spawn a login shell. Detaching never kills the session, so the agent keeps
working and you reattach later — the "task = live agent session" model, driven
from the phone. Proven end-to-end on a physical iPhone: the full Claude Code TUI
rendered in the app, running in a worktree on the Hetzner box, over Tailscale.

- xterm.js renders inside a react-native-webview (a raw PTY stream is ANSI
  escapes — it needs a real terminal emulator). xterm + fit addon are inlined
  (scripts/gen-xterm-assets.mjs → src/xterm-assets.ts) because the webview is
  offline/CSP-restricted; terminal-html.ts assembles them + the bridge.
- TerminalScreen.tsx wires the webview ↔ PTY with the same contract as the
  desktop Terminal.tsx and connect-cli: spawnShell/listForTask, snapshot + seq
  dedupe (buffer-until-ready), onData→write, fit→resize, onExit. TUI key toolbar
  (esc / ⇧tab / / / arrows / ^C) modeled on Termius, for keys the soft keyboard
  can't send.
- App.tsx: task cards are pressable → open TerminalScreen for that task.
- Deps: react-native-webview (native module → pod install on build), @xterm/xterm
  + @xterm/addon-fit (dist inlined). Metro bundle 561 modules / 2.57MB, mobile
  typecheck clean.

* fix(mobile): repaint the TUI on terminal reattach

Reopening a task's terminal replayed the scrollback but left a running
full-screen TUI (Claude Code) missing its live input box + footer: a same-size
reattach fires no SIGWINCH, so the TUI never repaints its alt-screen UI and you
see only the serialized snapshot. After applying the snapshot, jiggle the PTY
size (rows-1 → rows) to force SIGWINCH; the TUI then repaints everything from
scratch — authoritative, and better than trusting the snapshot for alt-screen
content. Harmless on a fresh shell (no TUI to redraw).

* feat(mobile,agents): board composer, agent-mode launch, project switcher, persisted connection

Mobile board is now a control surface, not just a viewer:
- Composer (src/Composer.tsx) at the board bottom: prompt + agent picker +
  auto-mode (yolo) + agents-mode toggles + send. Submitting creates a task and
  launches the agent, then opens its terminal — mirrors the desktop's
  create→spawnAgent sequence.
- Agent mode: engine gains `agentsCommand` ("claude agents") + agentCommand({
  agentMode}); protocol spawnAgent + dispatcher pass `agentMode` through (additive
  optional, no PROTOCOL_VERSION bump). Launches the tool's multi-agent board in the
  task's worktree (the PTY already cwd's there, so no --cwd).
- Board header redesigned: connection status dot (left, tap → connection page) +
  centered project dropdown; dropped the IP/app-icon/name chrome.
- Connection page: back-to-board + Disconnect when connected; host/port persisted
  via AsyncStorage (src/storage.ts) so a restart/reinstall keeps the box IP.
- Terminal reattach: widen the SIGWINCH redraw jiggle (350/900ms, cancel on
  unmount) so a running TUI fully repaints instead of coming back partial.

Verified headless: agents/protocol/server tc + 81 server tests green, mobile tc
clean, Metro bundle 569 modules. Box redeployed via login shell so the agents
list populates (system:hello → agents:["claude"]).

* fix(mobile,agents): scope agent mode to the worktree, hide prompt in agent mode, terminal scroll + keyboard dismiss

- Agent mode is now scoped: `claude agents` ignores the process cwd and shows the
  global board, so pass `--cwd <worktree>` explicitly (dispatcher hands
  agentCommand the task's worktreePath; single-quoted for spaces). Matches the
  `claude agents --cwd ~/repo` shape.
- Composer: agent mode passes no prompt (its board is interactive), so hide the
  textarea and show a hint; agent-mode tasks get a unique time-stamped name so the
  worktree branch doesn't collide.
- Terminal: touch-drag now scrolls the xterm scrollback (xterm only scrolls on
  wheel events, absent on touch → map drag to term.scrollLines; a tap re-focuses).
  Added a "Hide ⌨" button that blurs the hidden input so iOS dismisses the
  keyboard and you can see the full terminal.

* fix(mobile): agent-mode task-name field + terminal touch scroll + keyboard refit

- Composer: agent mode still needs to name the worktree, so keep the field but
  relabel it "TASK NAME" (placeholder makes clear it's a name, not a prompt); its
  value is passed as the explicit task name (ComposerSubmit.name), while normal
  mode still derives the name from the prompt.
- Terminal touch scroll: handle drags at the document level with capture +
  preventDefault so xterm's focused hidden textarea can't swallow the gesture (the
  previous #term-level passive handler didn't fire). A tap still re-focuses.
- Terminal fit: refit on keyboard show/hide (RN Keyboard events → __termFit) so
  the TUI is always sized to the visible area and its input box isn't clipped
  under the keyboard — the real fix for "see the full terminal".

* spike(mobile): native SwiftTerm terminal — SPM-in-Expo risk retired

The load-bearing risk from the /scalable ruling was whether SwiftTerm (SPM-only)
could be linked into this SDK-52 / old-arch / CocoaPods Expo app. It can: vendor
SwiftTerm's iOS sources (53 swift + 1 metal, Mac/AppKit dir dropped) into an Expo
local module and let CocoaPods compile them in-module. Build succeeded, 0 errors,
installed on the device.

- modules/expo-swiftterm: Expo local module (autolinked, no pbxproj surgery).
  ExpoSwifttermView wraps SwiftTerm's UIKit TerminalView (a UIScrollView → native
  scroll/selection/copy). TerminalViewDelegate.send → onInput event; clipboardCopy
  → UIPasteboard; feed(text:) exposed as a view AsyncFunction (imperative, so no
  streamed chunk is dropped by prop-diffing).
- src/NativeTerminalScreen.tsx: same PTY contract as the webview terminal
  (attach/spawn, snapshot+seq, onData→feed, input→write, size→resize) but rendering
  the native view. First onSizeChange is the "ready" signal (snapshot + flush).
- App.tsx: USE_NATIVE_TERMINAL flag switches to it; flip to false to fall back to
  the xterm-webview. Static banner feeds in the native init to prove render even
  before the stream is judged on-device.

* fix(mobile): native terminal render (color+layout) + agent brand logos

- SwiftTerm view was black: it fed the banner into a 0×0 grid (init frame .zero)
  and left the palette at defaults. Give the TerminalView a real initial frame,
  set an explicit dark theme (black bg / light fg), and feed only once the view
  has a non-zero layout. (feed-from-JS via the view function is still being judged
  on-device; the static banner proves render independently.)
- Agent logos: port the desktop's AgentIcon SVG paths (Claude #D97757, Codex,
  OpenCode) to react-native-svg; show them on task-card agent tags and in the
  composer's agent picker instead of initials.

* fix(mobile): render SwiftTerm via Auto Layout so the grid sizes (was black)

The black screen was the ExpoView's own background: the nested TerminalView had
its frame hand-set, which doesn't reliably fire the terminal's own layoutSubviews
— and that's what calls processSizeChange to compute the grid + render. Pin the
terminal with Auto Layout constraints (edge-to-edge) so its layout fires with real
bounds, and seed the banner via DispatchQueue.main.async (mirrors SwiftTerm's own
SwiftUI wrapper).

* fix(mobile): native SwiftTerm view now registers + renders (three real bugs)

The black screen was never a SwiftTerm render problem — RN couldn't find the
native component at all. The simulator (observing directly instead of guessing
across device rebuilds) revealed "No component found for ViewManagerAdapter_
ExpoSwiftterm", and three chained root causes:

1. Provider filter by deployment target: the module podspec declared iOS 16.4 but
   the app targets 15.1, so Expo autolinking's `supports_platform?` silently
   dropped it from the generated ExpoModulesProvider → never registered. Match the
   app target (SwiftTerm supports iOS 13+).
2. Missing vendored source: dropping the whole Mac/ dir also dropped the SHARED
   `AccessibilityService` (defined in Mac/ but unguarded, used by iOS) → compile
   error. Re-vendor Mac/ (its macOS-only files are `#if os(macOS)` no-ops on iOS).
3. Stale provider: `expo run:ios` skips pod install when the Podfile is unchanged,
   so adding a local module didn't regenerate the provider — force pod install.

Verified in the iOS Simulator: the native TerminalView renders (green ANSI banner,
computed a real 52×54 grid, correct 402×814 bounds). Diagnostics + the debug
early-return removed; the view is clean (black, edge-pinned via Auto Layout).

* feat(mobile): native terminal keyboard control (dismiss + avoid overlap)

SwiftTerm has no keyboard-avoidance of its own, so the keyboard overlapped the
terminal with no way to dismiss it. Fixes:
- Native blurKeyboard/focusKeyboard (resign/becomeFirstResponder) exposed as view
  functions; the JS handle + a "Hide ⌨" header button drive them.
- Wrap NativeTerminalScreen in KeyboardAvoidingView so the terminal shrinks above
  the keyboard (SwiftTerm's sizeChanged delegate then resizes the remote PTY).

Known SwiftTerm rough edges still open (its own "Selection/Accessibility lags"
caveat): re-selecting text after one selection is flaky. Tracked for follow-up.

* feat(mobile): fix terminal connection hang + native-terminal UX polish

Root-caused the "resolving session… never resolves" hang: the client code is
correct (proven — buildAteamApi over wsClientTransport resolves in ~28ms, and the
whole native terminal works live in the simulator). The device hang is a WS over
Tailscale going half-open on NAT/WireGuard idle with no close event, and the RPC
client has no per-call timeout → infinite wait.

- connection.ts: 15s keepalive (periodic system:hello) keeps outbound traffic
  flowing so the NAT/WireGuard mapping stays live; cleared on close. Uses an
  existing method — no box change.
- NativeTerminalScreen: per-call timeout on the resolve so any residual stall
  surfaces as an error + "Back to board" instead of a spinner forever. Plus our
  own shortcut toolbar (esc/⇧tab/arrows/^C — SwiftTerm's accessory bar is now off),
  a back-chevron icon button (was "‹ Board" text), white accent (not purple), and
  a KeyboardAvoidingView so the keyboard no longer overlaps.
- ExpoSwiftterm: inputAccessoryView = nil (plain iPhone keyboard); native
  blurKeyboard/focusKeyboard exposed for the Hide-keyboard + shortcut buttons.
- Composer: agent picker collapses to the selected agent + a tap-to-open popover;
  extra bottom padding so the mode buttons clear the iOS home indicator.

* feat(mobile): PgUp/PgDn in the native terminal toolbar — drive the TUI's own scroll

SwiftTerm scrolls the emulator's own scrollback (empty on an alt-screen), and on a
mouse-mode TUI it forwards touch-drags, not wheel events — so neither scrolls a
full-screen agent like Claude Code. Claude scrolls its conversation on PageUp/PageDown
in every mode, so surface those keys. Natural drag-to-scroll (sending the same keys)
is the follow-up once this confirms the mechanism.

* feat(mobile): drag-to-scroll in the native terminal (finger → PageUp/PageDown)

PageUp/PageDown confirmed to scroll Claude Code, so wire a vertical finger drag to
the same keys. Vendored SwiftTerm patch (marked ATEAM PATCH, re-apply on bump): its
mouse-mode pan gesture (active only for TUIs that enable mouse mode, e.g. Claude)
now maps vertical drag distance to PageUp/PageDown instead of forwarding a mouse-
drag (which a full-screen agent doesn't treat as scroll). Shell scrollback still
scrolls via the separate selection pan (mouse mode off). Page-granular; `step`
tunable for feel.

Also: PgUp/PgDn toolbar buttons no longer refocus the keyboard — scrolling is
reading, not typing (send() skips focusKeyboard for scroll keys).

* tune(mobile): more responsive drag-scroll (step ~1/14 view height per page)

Was 1/5 of the view height per PageUp — too much thumb travel for little scroll.
Lowered to ~1/14 so a short flick pages the conversation.

* feat(mobile): auto-reconnect on launch + toolbar/composer padding

- Auto-reconnect: on launch, if a box is saved, prefill AND connect to it so
  reopening the app lands straight on the live board (falls back to the connection
  screen with the error on failure). Extracted the one connect path into connectTo,
  used by both the Connect button and the launch auto-connect.
- Terminal shortcut toolbar: dropped maxHeight (it clipped) + extra bottom padding
  so the key row clears the iOS home indicator; horizontal scroll already handles
  overflow when the keys don't fit.
- Composer: a bit more bottom padding (34 → 42).

* feat(mobile): drop bottom padding when keyboard is up + terminal ⏎ key, PgUp/PgDn to the end

- useKeyboardVisible hook: Composer + terminal toolbar drop their home-indicator
  bottom padding while the keyboard is on screen (KeyboardAvoidingView already lifts
  them, and the keyboard covers that area) — no dead space above the keyboard.
- Terminal shortcut bar: add ⏎ (Enter), and move PgUp/PgDn to the end.

* feat(mobile): tap output → dismiss keyboard (cursor-relative, agent-agnostic)

Tapping the terminal's output/history now hides the keyboard; tapping the input
area brings it up. The "input area" is inferred from the terminal CURSOR (the
universal active-input location for any agent — Claude/Codex/OpenCode all keep
their cursor in their input), not a hardcoded per-agent layout. Vendored SwiftTerm
singleTap patch (marked ATEAM PATCH): near the cursor row → become first responder,
far from it → resign. Reuses SwiftTerm's existing calculateTapHit + displayBuffer
cursor position.

* feat(mobile): add a project by browsing the box's folders from the dropdown

The project dropdown now has an "Add project…" entry that opens a folder browser
over the box's filesystem — reusing fs.listDir (subfolders, each flagged if it holds
a .git) and projects.register, the same remote folder-pick the desktop uses. Starts
at the box's home dir, navigable (into subfolders + up); tap "Add" on a git repo to
register it. On register: refresh the board and select the new project.

* fix(mobile): project browser as a view-swap, not a nested modal (froze the app)

Opening the browser Modal while the project-dropdown Modal dismissed in the same
tick deadlocked iOS (can't transition two modals at once) → the app locked up.
Render the browser as a full-screen view-swap instead (like the terminal screen);
no nested modals.

* fix(mobile): repaint the native terminal after keyboard resize (agents mode)

The terminal grows/shrinks with the keyboard, but a full-screen TUI (Claude agents)
didn't always repaint cleanly after the rapid resize animation — leaving stale
height. On keyboardDidShow/Hide, once settled, jiggle the PTY size (rows-1 → rows)
to force a SIGWINCH full redraw at the final dimensions (same trick as the reattach
fix). Tracks the latest cols/rows from onSizeChange.

* feat(desktop): connection switcher — drive a remote box over SSH/Tailscale from the UI

The backend engine-seam (window.ateamHost: list/connect/current) shipped earlier
but had no faucet in the renderer. Add a topbar switcher: pick This Mac (local) or
any ~/.ssh/config host; shows the active engine + a live status dot, connecting
spinner, and inline connect errors (unreachable / daemon down / protocol mismatch).
On switch, every window drops its selection and reloads projects/tasks/agents from
the newly-active engine. Empty state points first-time users at ~/.ssh/config.

* docs: guide for running Ateam online (agents on a remote box over SSH/Tailscale)

* chore: release 0.1.29

* chore(mobile): prep for TestFlight — app name, iOS build number, export-compliance + publishing guide

- app.json is the source of truth (ios/ is prebuild-generated + gitignored):
  name mobile→Ateam, slug→ateam, add ios.buildNumber (bump per upload), and
  ITSAppUsesNonExemptEncryption=false (standard TLS/SSH/WS only) to skip the
  export-compliance prompt on every upload.
- docs/publishing-ios.md: internal-TestFlight-first pipeline, signing/App Store
  Connect setup, local-archive + EAS routes, and the review demo-box path for
  going wider (keeps local-first).

* chore(mobile): bundle id → com.clawnify.ateam (Clawnify namespace)

* chore(mobile): app name → Ateam Go (match App Store listing + icon)

* feat(mobile): app icon from the Ateam brand (full-bleed, iOS-safe, no alpha)

Adapt the desktop mission-control mark for iOS: full-bleed dark gradient + board
tiles, no macOS squircle/shadow (iOS applies its own mask), flattened opaque so it
passes App Store icon rules. Source kept as assets/icon.svg for future edits.

* feat(mobile): upgrade to Expo SDK 54 (RN 0.81) for Xcode 26 / iOS 26 SDK

Apple now requires builds made with the iOS 26 SDK (Xcode 26); SDK 52 can't produce
those. Upgrade to SDK 54 — the last SDK supporting the legacy architecture, so the
vendored SwiftTerm native view needs no Fabric migration (newArchEnabled pinned false).

- expo ^54, react-native 0.81.5, react 19.1.0 (expo install --fix)
- .npmrc legacy-peer-deps=true (npm can't resolve the react 18→19 transition otherwise)
- plugins/withFmtConstevalFix.js: build the fmt pod as C++17 — RN 0.81 vendors fmt
  11.0.2, whose consteval format-string checks fail under Xcode 26's Clang (fixed in
  fmt 12 upstream, not backported to 0.81). Durable config plugin (ios/ is regenerated).
- metro.config: drop disableHierarchicalLookup — SDK 54's expo pulls in nested
  transitive deps (whatwg-url-without-unicode → webidl-conversions) that the walk-up
  must resolve; bun-root shadowing is already prevented by the workspace exclusion.
- gitignore build/ (archive/export artifacts)

Verified: archive + export signed Apple Distribution (X2VZX44YM2), uploaded to
TestFlight (UUID 5f44c209). Xcode 26 also needs one-time: -downloadPlatform iOS +
-downloadComponent MetalToolchain.

* docs(mobile): update iOS publishing guide for Xcode 26 / SDK 54 (platform+metal downloads, proven build recipe)

* feat(mobile): auto-reattach to the box on foreground / after a drop

The daemon already keeps agents + PTYs alive across disconnects, but the mobile
client held a single WS with no reconnect path — backgrounding iOS or a network
flip left a dead socket and a frozen board. Add client-side reattach:

- connection.ts: surface an unexpected socket drop via connect(url,{onClose}) —
  distinguished from an app-initiated close(); add ping() (timed handshake) to
  tell a live socket from a half-open one.
- App.tsx: remember the connect target as intent; reattach on socket drop and on
  AppState 'active' (probe first, only reconnect if dead), with capped backoff
  (1s->15s). connGen bumps per (re)connect so the board's event stream and any
  open terminal rebind to the fresh api (terminal remounts + re-resolves its
  still-alive PTY). Explicit Disconnect clears the intent so it stays down.
- app.json: bump ios.buildNumber to 2.

Reuses RN-core AppState + the transport's existing onClose — no new native deps.
Verified: typecheck, lint, expo export (713 modules). On-device behaviour pending.

* feat(mobile): remember the last-selected project across launches

Reopening the app dropped you on project #1 every time. Persist the picked
project and restore it on launch:

- storage.ts: save/loadSelectedProject (AsyncStorage), mirroring the connection.
- App.tsx: persist on every explicit pick (dropdown + after registering a new
  project); load the saved id BEFORE the first connect so refresh() prefers it,
  with a self-correcting fallback to project #1 if it no longer exists on the box.

Typecheck + lint clean.

* feat(mobile): open the box's dev server in the phone browser (#73)

A one-tap preview of what the agent is building. No tunnel or port-forward: the
phone is already on the tailnet, so the box's dev server is directly reachable at
the same host we connected to.

- App.tsx: a ↗ button in the board header opens a modal showing http://<box>:<port>
  with an editable port (default 3000, remembered); Open → Linking.openURL. Host is
  target.current.host — the connected box's Tailscale address, filled automatically;
  disabled when not connected.
- storage.ts: load/savePreviewPort.

RN-core Linking, no new deps. Typecheck + lint + expo export clean.

* feat(mobile): attach a photo/screenshot to the agent (#72)

A +img button leads the terminal's key bar. Pick an image → stage its bytes on
the box via util.writeImageBytes → TYPE the returned path into the PTY. Typed
keystrokes (not a paste) are what trigger the agent's path→[Image #N] detection —
same mechanism (and same escapePath) the desktop terminal uses on a file drop.

- NativeTerminalScreen.tsx: +img key, attachImage handler, escapePath/extFromAsset.
- app.json: expo-image-picker plugin + photo-library permission string.
- deps: expo-image-picker ~17.0.11.
- bump ios.buildNumber to 3.

Reuses the existing writeImageBytes RPC — no server/protocol change. Typecheck +
lint + expo export clean; on-device attach path pending.

* refine(mobile): tmp-dir image staging, paperclip icon, neutral attach button

Follow-ups on the image-attach feature:
- server: writeImageBytes stages under <os-tmpdir>/ateam-attachments (like Termius)
  instead of <userDataDir>/attachments — transient, OS-cleaned, still pruned by the
  engine on startup. Applies to every client (desktop included); agent readability
  unchanged. Test + prune updated to match.
- mobile: the terminal attach button now shows a Feather paperclip icon (bundled,
  no new dep) instead of the +img text, styled like the other keys (no accent).
- bump ios.buildNumber to 4.

* feat(mobile): center content on iPad (cap width, keep phone layout intact)

supportsTablet was already on, so the app installs on iPad — but the phone layout
stretched edge-to-edge. Cap the board, connection form, and composer to a centered
CONTENT_MAX (720) column; the terminal stays full-width (more columns is better).
No-op on iPhone (720 > any screen), a centered column on iPad. No new deps.

Typecheck + lint + expo export clean.

* chore(mobile): bump ios.buildNumber to 5 (iPad centering build)

* feat(mobile): built-in demo mode (offline canned board + terminal) for App Review

Ateam is local-first with no shared backend a reviewer could sign into, so App Store
guideline 2.1 points to a built-in demo instead of demo credentials. Add a 'Try the
demo — no box needed' button on the connection screen that enters a fully offline
Connection: canned projects/tasks/agents and a realistic Claude Code terminal.

- src/demo.ts: fakes the RPC *transport* (answers the channels the app calls with
  canned DTOs) fed through the real createRpcClient + buildAteamApi, so every screen
  renders unchanged. No UI fork. Demo never reattaches and persists nothing.
- connection.ts: export mobileNative so demo reuses the same native stub.
- App.tsx: startDemo entry + the connection-screen button.
- bump ios.buildNumber to 6.

Also unblocks deterministic App Store screenshots. Typecheck + lint + expo export clean.

* docs(appstore): App Store submission package for Ateam Go

Privacy policy, App Review notes (demo-mode / guideline 2.1), listing copy
(name/subtitle/description/keywords/age-rating/App-Privacy answers), and the
publishing checklist + screenshot spec/pipeline. Text-only prep for the public
listing; internal TestFlight remains review-free.

* docs(readme): reword intro and requirements copy

* Readme update

* feat(desktop): multi-engine aggregation core for per-task environments

Groundwork for tagging each task with its environment (This Mac / a box) instead of
the global connection switch. A pure, Electron-free router that holds several backends
at once and: merges collection reads (projects/agents/loops), routes entity calls
(tasks/git/pty) to the owning engine via a learned id->backend map (all ids are
globally-unique UUIDs, so no namespacing), and falls back to local for un-routable
calls. Environment = the owning backend; no new task state or migration.

Unwired — host.ts keeps its single-active path untouched; this is the proven core.
7 unit tests (merge, route-by-project/terminal, create+learn, fallback); desktop
typecheck excludes *.test.ts (tests run via bun test, keeping bun types out of the
Electron-main tsconfig).

* docs: move internal App Store/publishing docs out of the public repo

The repo is public; these were internal operational runbooks. Remove the iOS
publishing runbook (it exposed the Apple Team ID + ASC Key ID — identifiers, not
the secret .p8, which was never committed) and the appstore/ package (checklist,
review notes, listing strategy) — their content now lives in Clawnify knowledge.
Keep the genuinely public docs: the online-ateam user guide and the privacy policy
(moved to docs/privacy-policy.md; personal email replaced with a contact placeholder).

Note: git history still contains the removed identifiers; rotate the ASC key if
that matters (the private key itself was never exposed).

* feat(mobile): add local-network permission + ateamgo:// deep links

- NSLocalNetworkUsageDescription: connecting to a box over a LAN IP trips iOS's
  local-network privacy; without the string users get a generic prompt or a silent
  failure. Surfaced by a pre-submission App Review self-audit.
- ateamgo:// URL scheme + a Linking handler: ateamgo://demo enters the offline demo,
  a trailing /task/<id> opens that task's terminal, /preview opens the preview modal.
  Primarily for driving screens for App Store screenshots, but a real deep-link primitive.

Goes in the next TestFlight build (buildNumber bump deferred to that build).

* chore(mobile): bump iOS build 6→7, check in ExportOptions.plist

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.

* chore: release 0.1.30

* Desktop: per-task remote environments (run a task on a connected box)

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.

* chore(mobile): iOS build 7→8 — rebuild for PROTOCOL_VERSION 2

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.

* Desktop 0.1.33: env-aware agent picker in the composer

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.

* Desktop 0.1.34: fix Run-on popover position + auto-mode hover state

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

* mobile: script to auto-distribute a TestFlight build to beta groups

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.

* mobile: distribute script skips internal groups (they auto-distribute)

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.

* ci: run typecheck, lint, and tests on PRs and main

* ci: fix root typecheck (per-project tsc, no build graph); gate on typecheck + tests

* ci: set a git identity so git-core initRepository tests can commit

* desktop: one-click box provisioning — Set up a box over SSH from the 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.

* desktop: create a box from a cloud provider (Hetzner)

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.

* desktop: install coding agents on a box

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

* install.sh: make a box task-ready — install gh + derive git identity

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.

* desktop: box-readiness checklist + inline agent Install

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.

* desktop: agent login isn't a box-setup pre-step

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.

* chore: release 0.1.38

* desktop: group remote-connection options + remember the chosen environment

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.

* desktop: show the box-readiness checklist after Set-up-over-SSH too

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

* chore: release 0.1.39

* docs: show the in-app Create a box flow in the README

Add the Create-a-box screenshot and a 'no box yet?' callout to the top of the
'Run your agents on a server' section, framing the in-app provisioning (region +
size → VPS + SSH key + Tailscale + engine) as the easy path above the manual recipes.

* docs: frame boxd as the first 'box provider'

Reframe the boxd recipe into a general 'Use a box provider' pattern — any service that
creates a box and writes an ssh_config alias plugs into Ateam with no integration (verified:
readSshHosts already enumerates ~/.ssh/config, boxd-aware). State the reusable recipe
(create → install.sh → gh/agent login → connect) up front, with boxd as the worked example;
future services are another short recipe. Fix the transition line to name the two DIY paths.

* feat: create a new project folder (iOS, desktop, server)

You could only register an existing repo. Now:
- server: register(path, {init:true}) creates the folder when missing before git init
  (mirrors the projectsClone handler); a dispatcher test drives it end to end.
- iOS: a 'New' button in Add project names a folder and creates+inits it on the box.
- desktop: the folder picker gets macOS's 'New Folder' button (createDirectory), and
  App.tsx already offers to git init a non-repo folder.

* chore(mobile): bump iOS buildNumber to 9

* chore: release 0.1.40

* Desktop: reconnect known boxes at launch, so remote tasks survive a restart

A box's tasks exist for the aggregate only while its engine is HELD, and a cold
start held only the local one — so the board opened with every remote task
missing until you created a remote task, whose connect put the box back into the
union and made all the others appear at once.

createHost now reconnects every box in the `hosts` registry (`known` — we've
handshaked it before), detached and in parallel: the board never waits on a box
that's asleep, each connect broadcasts its own arrival, and the renderer already
reconciles connection changes additively.

That surfaced a routing bug: projects:remoteUrl takes a projectId but was missing
from aggregate's ENTITY set, so a remote project's remote-URL lookup asked the
LOCAL engine — "Project not found: <id>" on every launch once boxes reconnect
themselves. Added it, with a regression test.

The project row now shows one icon per environment (monitor = this Mac, server =
a box) instead of the joined names, which crowded the row as soon as a repo
spanned more than one box; the name is the hover title and the accessible name.
Monitor rather than Laptop because lucide draws Laptop 15 units tall against
Server's 20, which read as mismatched at the same size.

Verified against a copy of a real userData dir: both known boxes' last_seen
updated ~17s after launch with no interaction, and the run logged no errors.

* Desktop: Mission Control listens for sessions instead of polling every task

The grid refreshed with setInterval(2500) around a SERIAL await chain — one
pty:listForTask per task, awaited in order. listForTask is routed per task by the
aggregate, so for a box-owned task each pass was an SSH round-trip: at ~20 remote
tasks and ~100ms RTT a pass could outlast its own 2.5s interval, with no
re-entrancy guard to stop passes stacking. Connecting known boxes at launch made
this worse, since the poll now leaves the machine from startup rather than after
you pick a box.

Nothing needed polling. Both spawn paths broadcast taskUpdated, a dying PTY
broadcasts ptyExit, and a box's events are forwarded to every window — so a timer
could not learn anything first. Now: fetch once, refresh on those events, and
fan the per-task fetch out with Promise.all (initial load is one RTT, not N).

Except ptySpawnShell did NOT broadcast, which the poll was quietly covering: a
shell opened in one window was invisible to every other client and to the phone
until something else touched the task. Made the broadcast symmetric with the
agent path, with a test that fails without it.

Also adds the prefers-reduced-motion block the renderer never had. Scoped: the
pulsing status dots stop (decoration — colour already carries the state), while
spinners keep spinning, since freezing them reads as a hung app.

* Cleanup: one privacy policy, and a test the main sync duplicated

Two tidies after syncing origin/main.

docs/privacy-policy.md is deleted. #131 added PRIVACY.md, linked from the README
and now the URL App Review will follow. The old file predates it, still carried an
unfilled '[set a support/privacy contact address before publishing]' placeholder
and an internal 'To publish:' note, and nothing referenced it. Two policies in a
public repo is a hazard when one is stale — a reviewer or a user could land on
either.

The dispatcher test is de-duplicated. Squash-merging #128 and then merging
origin/main back in re-added the shell-spawn broadcast test on top of the copy
already on main: git saw both sides adding the same lines independently, so it
kept both rather than conflicting. The file now matches main exactly.

* chore(mobile): bump iOS buildNumber to 10
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