# termfleet

**An ergonomic control center for the AI coding sessions you run — Claude Code,
Codex, and Gemini — across tools, projects, and machines.**

termfleet is an orchestration layer over the agents *already running in your
terminals*: one place to see which sessions need you and act on any of them by
name, with a visual fleet canvas when you want it. It attaches to the real
terminals on your host(s) — iTerm2, WezTerm, or a managed virtual-tmux server —
and gives every session a durable identity you can **list, triage, read, reply
to, wait on, continue, and close**, from a CLI built for an agent to drive, an
SDK, or a browser console. It is *not* another "spawn N isolated agents" runner;
it controls the agents you already have.

Current direction: retire Termfleet by migrating its consumers to existing
Supercode owners while preserving these capabilities. New shared functionality
belongs in those owners; Termfleet remains compatible until its consumers migrate.
See the [retirement roadmap](docs/ROADMAP.md#retirement-toward-supercode).

## Quickstart (local, no Docker/registry/account)

**Before you start**, install Node.js 20+ (runs the CLI) — that's the only
requirement for step 1 below. `tmux` and a logged-in agent CLI (`claude`,
`codex`, or `gemini`) are only needed once you get to step 2, acting on a
session (the quickstart fails fast without them there — see
[Troubleshooting](#troubleshooting)).

Install termfleet:

```sh
npm install -g termfleet
termfleet doctor   # checks node/tmux and which provider would be auto-targeted
```

### 1. Triage the sessions you already have — no daemon, nothing running

If you've used Claude Code, Codex, or Gemini CLI on this machine before,
termfleet can already see those conversations without you starting anything:

```sh
termfleet sessions recent --live --sort attention   # which sessions need you?
```

This reads your transcripts straight off disk (`~/.claude/projects`,
`~/.codex/sessions`, …) — no console, no provider process, no daemon. With no
provider running, `--live` explains rather than errors: rows come back with
`live: "unknown"` plus a notice ("no provider running — liveness unavailable;
showing durable sessions") instead of a hard failure. This is the whole
zero-server layer — the part that's useful even if you never touch anything
else in this Quickstart.

### 2. Act on a session — start a provider

Reading is free; *acting* (reply, launch, continue) needs a provider — the
process that actually owns a terminal for the session to run in. This is
also where `tmux` and a logged-in agent CLI become required:

```sh
termfleet provider serve --kind virtual-tmux --prefix dev --count 0 --port 7402 &

# the CLI auto-discovers the local provider you just started — no --url needed:
termfleet claude new --prompt "hello"               # launch a Claude session
termfleet sessions recent --live --sort attention   # rows are live now, not "unknown"
termfleet send --agent-session-id claude:<uuid> --text "go on" --enter
termfleet interrupt --agent-session-id claude:<uuid>
```

### 3. See it, and reach it remotely — the console (layer-1 upgrade)

Everything above is the whole local experience with no browser involved. The
console is an *upgrade* on top of it: a visual canvas over the same
providers, plus remote access via tunnel — not a separate system, and not
required to triage or act on a session.

```sh
termfleet console serve --name dev --port 7373 &
```

Open <http://127.0.0.1:7373>: the console auto-discovers the local provider,
so it renders as a "machine" on the canvas with your `hello` session running
inside it. **Start order doesn't matter** — the console pulls this discovery
itself (it reads the same `~/.termfleet/providers/` advertisement the CLI
trusts, on a 5s sweep), so running the provider before the console or the
console before the provider both converge to the same board within a couple of
seconds. (`termfleet --help` lists every command; `termfleet --version`
prints the version; `termfleet doctor` diagnoses a stuck setup.)

> **Power-user shortcut.** `termfleet ctl` collapses steps 2–3 (plus launching
> an orchestrator chat) into one idempotent command, and its console
> **tunnels by default** (pass `--no-tunnel` for local-only) — see
> [Console + providers, in depth](#console--providers-in-depth) below. That's
> a convenient default, not the recommended first thing to learn: the
> zero-server `sessions` moment in step 1 is what every user needs, whether or
> not they ever open a browser.

> **Working from a clone (contributing)?** You don't need a global install —
> run the CLI straight from source with `npx tsx src/cli.ts …` in place of
> `termfleet …`:
>
> ```sh
> git clone https://github.com/volter-ai/termfleet.git
> cd termfleet && npm install && npm run build   # build powers the browser console UI
> npx tsx src/cli.ts console serve --name dev --port 7373 &
> ```

## Concepts

- **Console** — the UI + aggregator: serves the board, proxies providers, merges
  the fleet across hosts. One per user.
- **Provider** — a process that owns terminal windows on a host (iTerm2 mirror,
  WezTerm, or virtual-tmux). The console talks to many.
- **Session** — *the unit you actually work with*: a Claude/Codex/Gemini
  conversation, addressed by `agentSessionId` — top-level `sessions`, `send`, and
  `close`, plus per-agent `claude|codex|gemini get|wait|continue` — never by
  hunting for a terminal id.

See `docs/ARCHITECTURE.md` for the layer thesis, the derive-don't-persist
principle, and the trust/scale/compatibility policies behind these concepts.

## What it's good at — and what it isn't

The edge is the **combination**: cross-host + cross-tool (Claude/Codex/Gemini) +
a visual canvas + optional team/org sharing, all through one session-first model.
Single-host triage of Claude sessions *alone* is also free in Claude Code's Agent
View; termfleet's value is spanning **hosts, tools, and people**.

Per-tool capability today:

| tool | discover / read | launch (`new`) | `wait` / `get` | `continue` |
|---|---|---|---|---|
| Claude | ✅ | ✅ | ✅ | ✅ |
| Codex | ✅ | ✅ (no caller-bound session id on a fresh launch) | ✅ | ✅ (`codex resume <uuid>`) |
| Gemini | ✅ | ✅ | ✅ | ✖ (resumes by index, not id) |

**Project status:** pre-1.0, moves fast, primarily one maintainer, heavily
AI-assisted. The macOS native path (iTerm2 via `osascript`) is most-tested; the
portable path is virtual-tmux/Docker; WezTerm-on-WSL is unvalidated. Providers
run real processes and serve host files — treat "provider reachable" as "full
host access granted." Default is loopback-only; read `SECURITY.md` before
exposing anything.

## Requirements

- **Minimum (local):** Node.js 20+ and `tmux` (`brew install tmux`).
- **To launch agents:** the agent CLI you'll use — `claude`, `codex`, or `gemini` —
  installed on PATH and **logged in**. The Quickstart's first `claude new` fails
  after ~45s against a missing or not-logged-in CLI, so install and sign in first
  (e.g. run `claude`, then `/login`).
- **Optional, per feature:** Docker (Docker workers) · macOS + iTerm2 (iTerm
  provider) · a deployed registry (team/remote — see below).

## Troubleshooting

Run `termfleet doctor` first — it reports `node`/`tmux`/`osascript`/`iTerm2`,
installed agent-CLI/terminal-host versions against the tested baselines in
[`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) (warning, never failing, on a
mismatch), the live local providers it found, and which provider a flagless
command would target (and why). Most "it didn't work" cases are one of these:

- **`claude new` fails after ~45s / "startup hang".** The agent CLI is missing
  or not logged in. Run the agent once interactively (`claude`, then `/login`;
  `codex`; `gemini`) so it's authenticated, then retry. termfleet treats "no
  transcript written within ~45s" as a hang (`TERMFLEET_AGENT_SESSION_FIRST_WRITE_MS`).
- **`provider serve --kind virtual-tmux` errors / no terminals appear.** `tmux`
  isn't installed or isn't on PATH. Install it (`brew install tmux` /
  `apt install tmux`); `termfleet doctor` should print `ok tmux`.
- **A flagless command prints `no_provider`.** No live local provider was found.
  Start one (`termfleet provider serve --kind virtual-tmux --count 1 --port 7402`),
  or pass `--url`/`--provider`, or set `TERMFLEET_PROVIDER_URL`. Resolution errors
  are emitted as JSON on stderr with a `remedy` field.
- **A flagless command prints `ambiguous_provider`.** More than one local
  provider is live. Pick one with `termfleet use <url>`, pass `--url`/`--provider`,
  or set `TERMFLEET_PROVIDER_URL`. Run `termfleet resolve` to see the candidates.
- **The console at `127.0.0.1:7373` is blank / shows no machine.** Confirm the
  provider process is still running and healthy
  (`termfleet providers health --url http://127.0.0.1:7402`), then reload the
  page (a hard reload — the console serves a built bundle). Auto-discovery only
  sees providers on the same host.
- **`EADDRINUSE` on startup.** The port is already taken (often a stale console
  or provider). Pick another `--port`, or stop the old process.
- **Installed from a clone and the UI is broken.** From source you must build the
  browser bundle once: `npm install && npm run build`. The published npm package
  already ships the built console.

`termfleet --help` (and `<command> --help`) lists every command; the
[CLI Commands](#cli-commands) section below is the full reference.

---

## Team & remote (opt-in): Docker Worker, Registry, Tunnel

This is the recommended path for a new user who wants terminals in Docker,
shared team discovery, and a dry run before handing the setup back to a human.

> **The registry is opt-in.** With `TERMFLEET_REGISTRY_URL` unset, termfleet is
> local-only and makes no outbound requests — that is the default experience (see
> the local Quickstart above). The steps in this section apply only if you have
> deployed and configured your own registry.
>
> **Deploy + seed it first** (the worker has no sign-up flow — accounts come from
> seeded users set at deploy time). From `packages/registry-worker`:
>
> ```sh
> npx wrangler secret put TERMFLEET_SEEDED_PASSWORD --env=""   # required; no default
> npx wrangler secret put TERMFLEET_SEEDED_USERS    --env=""   # JSON array of your team's accounts
> npx wrangler deploy --env=""
> ```
>
> See `packages/registry-worker/README.md` for the `TERMFLEET_SEEDED_USERS` shape.
> Then point `TERMFLEET_REGISTRY_URL` at the deployed worker's `/providers`
> endpoint, and sign in (step 3) as one of those seeded emails:
>
> ```text
> TERMFLEET_REGISTRY_URL=https://your-registry.example.com/providers
> ```

The Docker worker is still your machine's responsibility. The registry stores
auth, organization membership, shared provider records, canvas layout/comments,
and short-lived provider access tokens. It does not run Docker or tmux.

### 1. Install

```sh
git clone https://github.com/volter-ai/termfleet.git
cd termfleet
npm install
npm run build
```

Make sure Docker is running before starting a Docker worker. If you use Colima,
start it first and export the Docker socket if your shell needs it:

```sh
colima start
export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"
```

### 2. Start A Local Console

Persist the registry URL once, then start the console. The registry URL must be
the worker's `/providers` endpoint. Termfleet does not automatically read repo
`.env` files; the durable source of truth is `~/.termfleet/settings.json`.

```sh
export TERMFLEET_REGISTRY_URL="https://your-registry.example.com/providers"
npx tsx src/cli.ts chat --save --agent none --providers none --no-open-console
```

Confirm it was saved:

```sh
node -e 'console.log(require("node:fs").readFileSync(`${process.env.HOME}/.termfleet/settings.json`, "utf8"))' \
  | grep registryUrl
```

Now every later `console serve` or `ctl` recovers that registry URL, even from a
new shell:

```sh
npx tsx src/cli.ts console serve \
  --name local-console \
  --host 127.0.0.1 \
  --port 7373 \
  --local-adapters none
```

Open:

```text
http://127.0.0.1:7373/?session=local-console#view=providers
```

### 3. Sign In To The Registry

Use the browser sign-in flow or the CLI. The CLI stores its session in
`~/.termfleet/registry-auth.json` and uses the same registry as the console.

```sh
npx tsx src/cli.ts registry login \
  --identifier you@example.com \
  --password-stdin
```

Confirm the active user and organization:

```sh
npx tsx src/cli.ts registry session
npx tsx src/cli.ts registry list
```

### 4. Start Your Docker Worker

From the console UI, click `Start or register provider`, then `Start Docker
worker`.

The same action is available from the CLI:

```sh
npx tsx src/cli.ts registry start-docker-worker \
  --label "My Docker Worker"
```

This builds/starts the Compose worker and registers
`http://127.0.0.1:7374` with your local console. The worker runs as the
unprivileged `termfleet` user, and created sessions start in `/workspace`.

The raw Docker provider URL is an API endpoint for the console and CLI. Do not
expect `http://127.0.0.1:7374/` to be the full app. Use the console at
`http://127.0.0.1:7373/` to see and control it.

### 5. Dry Run Locally

Use the browser first. In Playwright or a normal browser:

1. Open `http://127.0.0.1:7373/?session=local-console#view=providers`.
2. Confirm `My Docker Worker` appears as connected.
3. Confirm the canvas shows a `Virtual tmux Space`.
4. Create or open a terminal, then type:

```sh
whoami && pwd && touch /workspace/termfleet-dryrun-ok && ls /workspace/termfleet-dryrun-ok
```

Expected output includes:

```text
termfleet
/workspace
/workspace/termfleet-dryrun-ok
```

Then verify the CLI can reach the same worker:

```sh
npx tsx src/cli.ts providers health --url http://127.0.0.1:7374
npx tsx src/cli.ts providers snapshot --url http://127.0.0.1:7374
npx tsx src/cli.ts list --url http://127.0.0.1:7374
```

For raw shell repair-only diagnostics, create a temporary shell and capture it:

```sh
npx tsx src/cli.ts new --repair \
  --url http://127.0.0.1:7374 \
  --break-glass \
  --cwd /workspace \
  --command "whoami && pwd"
```

Use `capture` on the returned `terminalId` value.

### 6. Log In Inside The Docker Worker

The Docker worker has isolated volumes for `/home/termfleet` and `/workspace`.
Credentials for Claude Code, Codex, GitHub CLI, SSH, and workspace files live
inside those Docker volumes, not on the host unless you explicitly mount them.

Use the console terminal for interactive logins:

```sh
claude /login
codex
gh auth login
```

Or open a repair shell from the CLI when the UI is not enough:

```sh
npx tsx src/cli.ts new --repair \
  --url http://127.0.0.1:7374 \
  --break-glass \
  --cwd /workspace \
  --command "gh auth status"
```

After login, prefer agent windows for normal work:

```sh
npx tsx src/cli.ts claude new \
  --url http://127.0.0.1:7374 \
  --name claude-worker \
  --cwd /workspace \
  --prompt "Inspect the workspace and report setup status."

npx tsx src/cli.ts codex new \
  --url http://127.0.0.1:7374 \
  --name codex-worker \
  --cwd /workspace \
  --prompt "Inspect the workspace and report setup status."
```

### 7. Make Your Docker Worker Available To The Team

Public/shared providers must be authenticated. Start a provider that requires a
registry-issued provider token, expose it with an HTTPS tunnel, then register
the tunnel URL as a shared provider.

> **Critical:** set `TERMFLEET_PROVIDER_PUBLIC_URL` to the provider's public
> tunnel origin (the same URL you register below). That is what switches the
> provider into **shared mode** — accepting only org-scoped provider tokens
> minted for the owning org. *Without it,* a public provider falls back to
> bare-session auth and any signed-in registry user of **any org** can reach it.
> You must therefore decide your tunnel origin first (reserve the subdomain).

Start an auth-required Docker provider on a local port (replace the public URL
with your reserved tunnel origin):

```sh
docker run --rm \
  --name termfleet-team-worker \
  -p 127.0.0.1:7374:7373 \
  -e TERMFLEET_AUTH_REGISTRY_URL=https://your-registry.example.com \
  -e TERMFLEET_ALLOWED_ORIGINS=https://your-registry.example.com \
  -e TERMFLEET_PROVIDER_PUBLIC_URL=https://YOUR-TUNNEL-SUBDOMAIN.your-tunnel-host.example.com \
  termfleet:local \
  node /app/dist/cli.js provider serve \
    --kind virtual-tmux \
    --host 0.0.0.0 \
    --port 7373 \
    --prefix termfleet-docker \
    --count 0 \
    --cwd /workspace \
    --tunnel
```

Expose `127.0.0.1:7374` through any HTTPS tunnel. For most users a standalone
tunnel like `ngrok` or `cloudflared` is the simplest option:

```sh
ngrok http 7374
```

If you run your own tunnel relay instead, point `TERMFLEET_LOCAL_TUNNEL_BIN` at
your own tunnel client binary and set `TERMFLEET_TUNNEL_SERVER_URL` to the relay
URL (the `--tunnel` flag then spawns that client for you). Whatever client you
use, it must forward to the provider port and not add its own auth gate here; the
Termfleet provider
is already enforcing registry-issued provider tokens because it was started
with `--tunnel`.

Register the HTTPS tunnel URL in the active registry organization:

```sh
npx tsx src/cli.ts registry register-shared \
  --url https://YOUR-TUNNEL-SUBDOMAIN.your-tunnel-host.example.com \
  --label "Your Name Docker Worker" \
  --requires-auth
```

Team members in the same active organization will see it under Shared
Providers in the cloud console and any local console using the same
registry.

### 8. Use It Remotely

Open the cloud console from another machine:

```text
https://your-registry.example.com#view=providers
```

Sign in, confirm the shared provider is connected, then use the canvas normally.

The CLI can also use shared providers by name after login:

```sh
npx tsx src/cli.ts registry login --identifier you@example.com --password-stdin
npx tsx src/cli.ts providers snapshot --provider "Your Name Docker Worker"
npx tsx src/cli.ts claude new \
  --provider "Your Name Docker Worker" \
  --cwd /workspace \
  --prompt "Confirm this remote Docker worker is usable."
```

Remove a shared provider when the tunnel or worker is no longer meant to be
available:

```sh
npx tsx src/cli.ts registry unregister-shared \
  --url https://YOUR-TUNNEL-SUBDOMAIN.your-tunnel-host.example.com
```

## Feature Guide

Termfleet exposes the same provider features through the browser console, CLI,
and SDK.

- Providers: start local virtual tmux, Docker, iTerm, or WezTerm providers;
  register local URLs; register shared team URLs; remove stale providers.
- Canvas: pan, zoom, fit view, inspect provider groups, and arrange terminal
  windows.
- Terminal windows: create agent windows, move/resize windows, close windows,
  capture output, and send input.
- Agents: launch Claude Code, Codex, or Gemini in provider-owned terminals and
  read structured session state with `claude get`, `codex get`, `gemini get`, and
  `wait`.
- Files: read, write, stat, and copy files through provider filesystems.
- Folders/files: open folder and file views on the provider canvas.
- Iframes: open an HTTP app or preview URL on the provider canvas.
- Multiplayer: registry-backed canvas layout, comments, presence, cursors,
  viewport, and selection sync are scoped to the active organization.
- Security: shared public providers should use provider auth (`--tunnel` plus
  shared registration with `--requires-auth`); raw terminal shells are
  repair-only with `--break-glass`.

### Assignment execution boundaries (macOS and Linux)

Policy clients can request a time-bounded, default-deny network boundary on an
agent launch. Providers refuse that request unless a trusted adapter is
installed; older or unconfigured providers never silently launch it without
the boundary.

```sh
npm run build
sudo ./dist/execution-boundary-install.js
export TERMFLEET_EXECUTION_BOUNDARY_BIN=/usr/local/libexec/termfleet-execution-boundary
termfleet provider serve --kind virtual-tmux --count 0 --port 7402
```

The bundled adapter uses macOS Seatbelt or Linux Bubblewrap (`bubblewrap` must
be installed before running the installer). On Linux the installer creates a
root-owned private setuid Bubblewrap copy so the boundary works when the host
disables unprivileged user namespaces; that launcher is not mounted into the
executor filesystem. Direct networking is denied and
approved host/port pairs pass only through its filtering HTTP/CONNECT proxy;
macOS may reach an exact approved loopback port directly, while Linux crosses
the isolated network namespace through the filter's Unix-socket bridge. The
same policy denies declared filesystem roots, makes staged input roots
read-only, and denies writes outside the exact declared workspace, output, and
private runtime roots. The helper allows reads only from trusted system
runtimes and the exact declared inputs, workspace, output, and private runtime;
gives the executor a fresh `HOME` and `TMPDIR`; uses a trusted system `PATH`;
and isolates host IPC. Trusted workspace preparation runs before the executor
enters the boundary. The provider advertises
`capabilities.executionBoundary: "external-adapter-v1"` only while this adapter
is configured. The adapter file must be absolute, root-owned, executable, and
not group- or world-writable.

## Cooperative Hook (Opt-In)

Termfleet triages agent state by watching from the outside — screen content,
transcript files, process trees. That works well but is inherently a guess.
If you use Claude Code, you can optionally add a small hook so termfleet
learns turn boundaries directly from Claude Code itself instead of inferring
them — the highest-confidence signal in the evidence ladder
(`source: "cooperative"`). See `docs/architecture/cooperative-hook.md` for the
full design rationale (why it's engineered to be perfectly safe to skip).

**This is entirely optional.** Termfleet never installs it for you, never
edits any of your Claude Code config files, and behaves identically whether
you install it or not.

### Install

Add this to your Claude Code hooks config — per-project
`.claude/settings.local.json` is recommended (personal, not checked into git);
`~/.claude/settings.json` works too if you want it for every project:

```json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "termfleet cooperative-signal" }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "termfleet cooperative-signal" }
        ]
      }
    ]
  }
}
```

If the file already has a `hooks` key (or a `UserPromptSubmit`/`Stop` key
under it), merge these entries in rather than replacing the file — don't
delete hooks you already have configured for something else.

That's it. The next Claude Code session started (or resumed) after you save
the file will pick up the hook. No termfleet restart is needed; no other
config changes are required. If `termfleet` isn't on your `PATH`, use the
absolute path to it in `"command"` instead.

### Verify It's Working

```sh
termfleet sessions recent --live --sort attention | grep -A2 '"attention"'
```

A session termfleet is currently observing that hook for shows
`"source": "cooperative"` inside its `attention` field (`{value, source,
confidence}`). If you don't see it, confirm a provider is running and
reachable (`termfleet resolve`) — the hook posts to whichever provider
`$TERMFLEET_PROVIDER_URL`/auto-discovery resolves to, same as any other
`termfleet` command.

### Uninstall

Remove the `UserPromptSubmit`/`Stop` entries you added from your hooks config
(or delete the whole file if `cooperative-signal` was the only thing in it) —
that's the entire uninstall. There is nothing else to clean up: termfleet
stores cooperative signals in memory only (never on disk, never in your
project), so removing the hook is instantly and permanently as if it was never
installed. The very next observe pass for any session that hook was covering
falls back to the next evidence rung (transcript/process-tree/screen
heuristics) automatically — no restart, no flag to flip.

## Provider Model

```text
Console / CLI
  -> ProviderClient SDK
    -> Provider Instance URL
      -> ProviderEngine
        -> ItermDriver, VirtualTmuxDriver, or WeztermDriver
          -> iTerm, tmux virtual display, or WezTerm terminals
```

The console server owns the browser UI, registry/auth, and local provider
materialization. Provider servers own terminal/window/display state only. The CLI
uses the same `ProviderClient` SDK as the console for provider operations, so it
does not need the console server for normal provider control.

## Console + providers, in depth

A deeper look at the local setup the [Quickstart](#quickstart-local-nodockerregistryaccount)
covers: run the console on your computer, start one or more provider servers, then
drive them from the console or CLI. No registry and no tunnel are required — leave
`TERMFLEET_REGISTRY_URL` unset and termfleet makes no outbound requests. Locally the
console **auto-discovers** running providers (and the CLI auto-resolves a target),
so explicit registration is optional — the SDK/CLI registration shown below is for
when you want to register a provider *explicitly* or by a remote URL.

> The numbered walkthrough below runs from a clone (`npx tsx src/cli.ts …`); with
> a global install, substitute `termfleet …` (no `npm install`/`npm run build`).

For the fastest path into a ready control session, use the idempotent `ctl`
command (the front door):

```sh
termfleet ctl
```

`ctl` ensures the local console is running (**tunneled by default** — pass
`--no-tunnel` for a local-only console), ensures the configured local providers
exist, then reuses or starts the orchestrator chat in your fleet-control folder
and attaches you to it. Flags override saved settings for one run; add `--save`
to make those choices the new default:

```sh
termfleet ctl --cwd ~/fleet-control --save
termfleet ctl --no-tunnel
```

> **`termfleet chat` is deprecated** — use `ctl`. `chat` still works (and stays
> the home of agentless boot `--agent none`, an arbitrary `--cwd`, and
> JSON-report-by-default scripting), but every run prints a deprecation notice:
>
> ```sh
> termfleet chat --agent none --providers virtual-tmux --no-open-console  # agentless fleet boot
> ```

1. Start the console:

```sh
npm install
npm run build
npx tsx src/cli.ts console serve --name local-console --host 127.0.0.1 --port 7373
```

2. Start a provider server:

```sh
npx tsx src/cli.ts provider serve --kind virtual-tmux --prefix termfleet-virtual --count 2 --host 127.0.0.1 --port 7376
```

On a shared development host, cap admitted agents at both the provider and
whole-machine levels, reserve disk/memory headroom, and route regenerable build
and scratch data to a roomier disk. Configured roots must already exist and be
absolute; a missing/unmounted disk fails before Termfleet acquires a window.

```sh
mkdir -p /Volumes/BuildSSD/termfleet-admission /Volumes/BuildSSD/termfleet-cargo /Volumes/BuildSSD/termfleet-tmp
TERMFLEET_BUILD_JOBS=3 \
TERMFLEET_CARGO_TARGET_ROOT=/Volumes/BuildSSD/termfleet-cargo \
TERMFLEET_HOST_ADMISSION_ROOT=/Volumes/BuildSSD/termfleet-admission \
TERMFLEET_MAX_ACTIVE_AGENTS=2 \
TERMFLEET_MAX_HOST_ACTIVE_AGENTS=6 \
TERMFLEET_MIN_HOST_DISK_FREE_GB=25 \
TERMFLEET_MIN_HOST_MEMORY_FREE_PERCENT=20 \
TERMFLEET_SESSION_TMP_ROOT=/Volumes/BuildSSD/termfleet-tmp \
TERMFLEET_VGAI_TEST_WORKERS=4 \
TERMFLEET_VGAI_TYPECHECK_CONCURRENCY=2 \
  npx tsx src/cli.ts provider serve --kind virtual-tmux --prefix termfleet-virtual --count 2 --host 127.0.0.1 --port 7376
```

The machine-wide lock serializes agent startup across local providers, then
counts recognized agent processes before admitting the next one. Managed
scratch directories carry ownership metadata and are marked closed when their
window closes. Closed directories and crash remnants can be reviewed and
removed conservatively; dry-run is the default and dirty Git worktrees are
always kept.

```sh
TERMFLEET_SESSION_TMP_ROOT=/Volumes/BuildSSD/termfleet-tmp npm run cleanup:scratch
TERMFLEET_SESSION_TMP_ROOT=/Volumes/BuildSSD/termfleet-tmp npm run cleanup:scratch -- --apply
```

3. *(Optional — local providers are auto-discovered.)* To register a provider
   with the console explicitly (e.g. a remote URL), use the SDK:

```ts
import { ProviderClient, providerRefFromUrl } from "termfleet";

const consoleClient = new ProviderClient(providerRefFromUrl("http://127.0.0.1:7373", "Local console"));

const provider = await consoleClient.registerLocalProvider({
  baseUrl: "http://127.0.0.1:7376",
  label: "Virtual tmux"
});

console.log({ provider });
consoleClient.disconnect();
```

The console can also materialize local providers itself through
`startLocalProvider`. Docker workers use the same provider protocol: the
container runs a virtual tmux provider and the console registers that provider
URL.

4. Expose a provider with a tunnel before registering a shared URL:

```sh
ngrok http 7376
```

Use any tunnel or LAN URL that other users can reach. Keep both the provider and
tunnel running while the provider is shared. Providers are unauthenticated by
default; mark a provider as auth-required when its own `--tunnel` mode exposes
it publicly.

5. The CLI exposes the same SDK path as a convenience wrapper:

```sh
npx tsx src/cli.ts registry login --identifier you@example.com --password-stdin
npx tsx src/cli.ts registry start-docker-worker
npx tsx src/cli.ts registry register-shared \
  --url https://your-provider-tunnel.example.com \
  --alias your-worker \
  --label "Your Name Provider" \
  --requires-auth
```

The CLI stores its registry session token in `~/.termfleet/registry-auth.json`.
The console also uses this token for registry reads when the browser has not
supplied its own signed-in session.

6. Open the console:

```sh
open http://127.0.0.1:7373/
```

The console starts local iTerm and WezTerm adapters by default, so they appear
under Console-Managed Providers when their backing terminal apps are available.
Virtual tmux, Docker worker, and shared providers appear in their own sidebar
groups after they are started or registered.

## WezTerm on WSL/Windows (experimental)

On WSL/Windows the WezTerm provider is the native-terminal candidate. It's
implemented and smoke-tested on macOS but **not yet validated on a real WSL
machine** — keep the virtual-tmux provider as the WSL fallback until it is.

To try it: install WezTerm on Windows, use a WSL distro with `node`/`npm`/`tmux`
and the termfleet checkout, and confirm `wezterm.exe` and `powershell.exe` are
visible from WSL (it drives the terminal via `wezterm.exe` and Win32 window
positioning via `powershell.exe`). Then:

```sh
npx tsx src/cli.ts provider serve --kind wezterm --prefix wsl-wezterm --count 1 --host 0.0.0.0 --port 7377
```

`http://localhost:7377/healthz` should return `"provider":"wezterm"`. If
enumeration or window positioning misbehaves from WSL, that's the unvalidated
path — please file an issue (see CONTRIBUTING).

## Start A Provider

A provider owns terminal windows on a host. Run one on its own port (distinct
from the console's):

```sh
termfleet provider serve --kind virtual-tmux --prefix agent-a --count 2 --host 127.0.0.1 --port 7402
```

Open the provider canvas, or control it entirely from the CLI:

```sh
open http://127.0.0.1:7402/
termfleet providers health   --url http://127.0.0.1:7402
termfleet providers snapshot --url http://127.0.0.1:7402
```

## Run The Console

Run a console when you want to see the Termfleet app in the browser and load
providers from the local pool and shared registry.

```sh
termfleet console serve --name local-console --host 127.0.0.1 --port 7373
```

Open the app:

```sh
open http://127.0.0.1:7373/
```

The console automatically loads providers from `.termfleet-registry.json` and,
when `TERMFLEET_REGISTRY_URL` is set, from the shared registry (it is opt-in;
without that env var the console stays local-only). The Providers sidebar can
register any reachable URL
that implements the provider API. Provider names shown in the sidebar come from
the registry's `label` field.

The sidebar can also start additional local providers for you. `Start virtual
tmux` and `Start Docker worker` add those provider URLs to the console runtime
provider list. They do not need to be written to `.termfleet-registry.json`
because the console process owns those materialized providers directly. The local
iTerm provider is on by default.

## Start The Docker Worker

Use the Docker worker when you want Termfleet terminals to run inside a Docker
container. The container exposes a normal virtual tmux provider endpoint to the
console or CLI.

1. Clone and install the project:

```sh
git clone https://github.com/volter-ai/termfleet.git
cd termfleet
npm install
```

2. Start the Docker worker with Compose:

```sh
docker compose up --build
```

Compose publishes the provider at `http://127.0.0.1:7374`. The provider itself
listens on port `7373` inside the container.

3. Open the provider canvas:

```sh
open http://127.0.0.1:7374/
```

The Docker worker's virtual tmux provider should render its terminal windows in
the browser.

To see the worker provider from a separate console, keep this Docker process
running and register its reachable URL from the console's Providers sidebar.

To run without Compose:

```sh
npm run docker:build
docker run --rm \
  -p 127.0.0.1:7374:7373 \
  termfleet:local
```

By default the worker provider starts with no panels. Panels are created
explicitly with `claude new`, `codex new`, `folder open`, or repair commands.
To seed initial terminal panels for debugging or to change the prefix, run the
provider CLI directly in the built image:

```sh
docker run --rm \
  -p 127.0.0.1:7374:7373 \
  termfleet:local \
  node dist/cli.js provider serve --kind virtual-tmux \
    --host 0.0.0.0 \
    --port 7373 \
    --prefix my-docker-worker \
    --count 2
```

### Bundled Agent CLIs

The Docker image ships with agent tooling so the terminals inside the worker are
ready to drive: the GitHub CLI (`gh`), Claude Code
(`@anthropic-ai/claude-code`), and Codex (`@openai/codex`). The Gemini CLI is
**not** bundled — install it in the worker (or a derived image) if you want
`gemini new` there. The binaries are installed in the image; credentials live in
the container filesystem or mounted home directory supplied when the worker is
created.

The `termfleet:local` image does not include or mount local machine agent credentials.
Each Docker worker gets its own Compose-project-scoped Docker volumes for
`/home/termfleet` and `/workspace`, so Claude, Codex, GitHub CLI, SSH config,
and workspace files are isolated per worker. Run `claude /login`, `codex`, or
`gh auth login` inside the worker that should own those credentials.

## Create Another Docker Worker

The `Start Docker worker` button starts the default worker at
`http://127.0.0.1:7374`. To run another worker provider at a different local
port, build the image and run a second container:

```sh
npm run docker:build
docker run --rm \
  -p 127.0.0.1:7405:7373 \
  termfleet:local \
  node dist/cli.js provider serve --kind virtual-tmux \
    --host 0.0.0.0 \
    --port 7373 \
    --prefix termfleet-docker-7405 \
    --count 2
```

This creates another Docker worker provider at `http://127.0.0.1:7405`. To make it
available through the app, create a tunnel for port `7405` and register that
tunnel URL.

## Tunnel And Register A Provider

To share a provider with the team, expose its local port through a tunnel and
register the tunnel URL under Shared Providers.

For the default Docker worker provider:

```sh
ngrok http 7374
```

For an additional worker provider running on port `7405`:

```sh
ngrok http 7405
```

Copy the HTTPS forwarding URL from the tunnel output, then open the console
at `http://127.0.0.1:7373/` and register it in the Providers sidebar:

- Section: Shared Providers
- Name: a useful team-visible name, such as `Alice Worker`
- URL: the tunnel HTTPS URL
- Click `Register`

Registered tunnel URLs are written to the shared registry. The tunnel process
and provider process both need to stay running while the team uses that
provider.

## Add It To The Registered Pool

A Termfleet console loads runtime local providers, console-local saved
providers, and shared remote providers. Runtime local providers are the iTerm
and WezTerm adapters started by the console process; they appear in the provider
list while that console process is running and do not need to be saved to
`.termfleet-registry.json`. Manually added local providers can still be stored
in `.termfleet-registry.json`. Remote providers are read from and written to the
shared registry URL you configure via `TERMFLEET_REGISTRY_URL`:

```text
https://your-registry.example.com/providers
```

The registry is opt-in: with `TERMFLEET_REGISTRY_URL` unset, termfleet is
local-only and never contacts a remote registry. Set it to point every user at
the same persistent remote pool — deploy your own registry worker from
`packages/registry-worker`. The registry must return either an array of provider
records or an object with a `providers` array. Each provider needs a reachable
`baseUrl`; `label` is optional but recommended.

A deployed registry worker is the normal team registry. Only run a local registry
when developing the registry service itself:

```sh
npx tsx src/cli.ts serve-registry \
  --host 127.0.0.1 \
  --port 7401 \
  --file .termfleet-remote-registry.json
```

If you deliberately expose that local development registry, point any HTTPS
tunnel at port `7401` with its own auth gate disabled so the registry service can
handle `/auth/*` and `/providers` authentication itself:

```sh
ngrok http 7401
```

(`ngrok` and `cloudflared` are the simplest standalone tunnels. To use a
self-hosted relay instead, set `TERMFLEET_LOCAL_TUNNEL_BIN` to your own tunnel
client binary and `TERMFLEET_TUNNEL_SERVER_URL` to the relay URL.)

Keep both the registry server and the tunnel process running.
The remote registry file should contain only remote or tunnel URLs, not
localhost providers.

Open the console at `http://127.0.0.1:7373/` and use the Providers sidebar.
The sidebar groups providers by role:

- Console-Managed Providers: console-started iTerm and WezTerm adapters.
- Virtual Servers: local virtual tmux providers.
- Docker Workers: Docker-hosted virtual tmux providers.
- Shared Providers: remote provider URLs from the shared registry.

You only need a reachable provider URL; the person running the provider does not
have to be the same person who registers it.

- Name: `Worker Provider` or any display name you want.
- URL: a tunnel URL for your provider or someone else's provider.
- Requires auth: enable this only when the provider URL points at a
  `termfleet provider serve --tunnel` instance.
- Click `Register` in the Shared Providers form.

The local iTerm and WezTerm start buttons materialize default loopback URLs into
the console runtime provider list only. The register form writes tunnel URLs to
the shared registry. Refreshing the console will load persisted local virtual
tmux, Docker worker, and shared provider URLs from `/api/registry/providers`.

Termfleet uses one configured remote registry service with organization-scoped
provider records. Switching the active organization changes which shared
providers you see and manage. Removing a local provider deletes only this
console's local registry entry. Unregistering a shared provider deletes that
provider from the active organization's shared registry so other org members
stop seeing it.

Example shared registry response:

```json
{
  "providers": [
    {
      "baseUrl": "http://127.0.0.1:7374",
      "label": "Docker Worker"
    }
  ]
}
```

Use a URL that the intended console or CLI client can reach. A local console can register
`http://127.0.0.1:7374` into its local pool. To share the same provider with
other users, register a LAN hostname or tunnel URL so their machines can reach
it.

To use a different registry, start the console with `TERMFLEET_REGISTRY_URL`
pointed at that registry endpoint:

```sh
TERMFLEET_REGISTRY_URL="https://registry.example.com/providers" \
  npx tsx src/cli.ts console serve --name local-console --port 7373
```

Open the console and it will automatically connect to every local provider
plus every provider returned by the remote registry:

```sh
open http://127.0.0.1:7373/
```

Use the Providers sidebar to verify what the console sees.

## Small Public Deployment

For a small public deployment, expose the console through tunnel mode instead of
publishing the raw Termfleet port:

```sh
TERMFLEET_CONSOLE_TOKEN="a-strong-random-secret" \
TERMFLEET_ALLOWED_ORIGINS="https://your-console-tunnel.example.com" \
TERMFLEET_CONSOLE_ALLOWED_ORGS="org_your_org" \
  npx tsx src/cli.ts console serve --name public-console --port 7373 --tunnel --tunnel-id your-stable-console-id
```

> **A tunnel always has an auth backend — it never serves the fleet
> unauthenticated.** The backend resolves by precedence: `TERMFLEET_CONSOLE_TOKEN`
> (a strong operator secret) → a configured registry (user accounts) → else the
> console auto-provisions a **6-digit access passcode** and prints it at startup
> (`ctl`/`chat` print it too). The passcode is a rate-limited login factor exchanged at
> `/console-login` — fine for personal use, but set `TERMFLEET_CONSOLE_TOKEN` (or
> use a registry) for anything shared or long-lived, where a strong secret beats a
> 6-digit code. Loopback access is always exempt; only remote/tunnel peers are
> gated. If a passcode leaks, run `termfleet console rotate-passcode` and restart
> the console.

### Tunnel client setup

The ordinary workflow is in the console frontend. Open **Remote access** in the
bottom status bar for the quick QR handoff, or **Settings → Remote Access** for
the full controls. Start, stop, and switch providers live:

- **Automatic** prefers a configured stable relay, then a zero-account
  Cloudflare Quick Tunnel, then an already-configured ngrok installation.
- **Cloudflare** needs `cloudflared` on `PATH`; Quick Tunnels need no account and
  return a temporary URL.
- **ngrok** is offered only when `ngrok config check` succeeds. Termfleet never
  starts an interactive sign-in flow for it.
- **Stable relay** uses `TERMFLEET_TUNNEL_SERVER_URL` and preserves its URL across
  restarts.

Once connected, scan the locally generated QR code and enter the displayed
6-digit access code on the phone. The code is surfaced only to the local owner;
the public URL remains protected by the rate-limited passcode exchange. Runtime
choices persist in `~/.termfleet/settings.json`.

`--tunnel` remains the automation/startup form and uses the persisted provider.
`--no-tunnel` starts local-only; remote access can still be enabled later from
the local frontend without restarting the console.

For a custom stable relay client, point `TERMFLEET_LOCAL_TUNNEL_BIN` at your own
compatible binary and set `TERMFLEET_TUNNEL_SERVER_URL` to its relay URL.

If your client isn't a directly executable file — for example a script entry the
shell can't run as-is — wrap it in a small launcher and point
`TERMFLEET_LOCAL_TUNNEL_BIN` at the launcher:

```sh
mkdir -p ~/.termfleet/bin
cat > ~/.termfleet/bin/my-tunnel <<'SH'
#!/bin/sh
exec /path/to/your/tunnel-client "$@"
SH
chmod +x ~/.termfleet/bin/my-tunnel
export TERMFLEET_LOCAL_TUNNEL_BIN="$HOME/.termfleet/bin/my-tunnel"
export TERMFLEET_TUNNEL_SERVER_URL="https://your-relay.example.com"
```

These two values (plus `TERMFLEET_REGISTRY_URL`) are **persisted**: run
`termfleet ctl --save` once from a shell that has them set, and every later
`console serve`/`ctl` recovers them from `~/.termfleet/settings.json`. Registry
configuration remains available for shared-provider discovery, but a personal
QR handoff uses the local access code and does not require a registry account.

Keep provider and Docker ports bound to `127.0.0.1`. If you run the registry on
a public interface, set `TERMFLEET_SEEDED_PASSWORD` and
`TERMFLEET_REGISTRY_ALLOWED_ORIGINS` explicitly; the registry refuses public
startup with the default seeded password.

## AI Agent Workflow

termfleet is built to be driven by an AI orchestrator, and the model is
**session-first**: you discover, read, and act on sessions by `agentSessionId` —
you almost never name a terminal or window. (For browser-terminal validation,
run the agent CLIs in their interactive TUI mode; don't use non-interactive
shortcuts like `claude -p` / `codex exec`, which prove a different execution path
than a user driving the terminal through termfleet.)

**1. Triage — which sessions need you?**

```sh
termfleet sessions recent --live --sort attention   # the attention / triage view
termfleet sessions search --query "deploy"           # find a session by content
```

One list across every provider, sorted by which sessions are waiting on you.

**2. Read a session — by id, with a since-cursor for deltas:**

```sh
termfleet claude get --agent-session-id claude:<uuid> --cwd /workspace
termfleet claude get --agent-session-id claude:<uuid> --since 12   # only items after #12
```

Or block until the session finishes its current turn — the primitive you build
loops on:

```sh
termfleet claude wait --agent-session-id claude:<uuid> --after-count 5 --timeout-ms 600000
```

`--after-count N` waits for a turn *after* the Nth message, so a `wait` issued
right after a `send` returns the new reply, not the one already there.

**3. Act on a session — reply or close, keyed by id:**

```sh
termfleet send         --agent-session-id claude:<uuid> --text "run the tests" --enter
termfleet sessions set --agent-session-id claude:<uuid> --name "Auth refactor"
termfleet close        --agent-session-id claude:<uuid>
```

termfleet resolves session → window → terminal internally; you never supply a
`terminalId`.

SDK schedulers can call
`ProviderClient.prepareSessionContext(agentSessionId, { cwd })` before starting
another turn. Termfleet chooses the installed agent's context-preparation
command, proves the result through its normalized transcript, and waits for the
provider's semantic `session_waiting` state. If the session is active, the
operation returns `status: "deferred"` without writing input. The lower-level
`sendToSession(..., { ifIdleOnly: true })` exposes the same provider-side
compare-and-act boundary for the following instruction.

`sessions set` sets a session's **display name** — the patch-shaped edit seam
shared by the CLI, the SDK (`ProviderClient.updateSession`), MCP
(`update_session`), and the HTTP API (`PATCH /api/agents/<agent>/sessions/<id>`).
The name is stored durably (session-keyed, so it outlives the session) and shows
in the console as both the panel name and the durable session-list row,
overriding the auto-derived title. Pass `--name ""` to clear it. `--id WINDOW_ID`
/ `--terminal-id ID` are accepted as break-glass, resolved to their live session.

**4. Launch or resume:**

```sh
termfleet claude new --cwd /workspace --prompt "Inspect this repo and report before editing."
termfleet codex  new --cwd /workspace --name todo-codex
termfleet claude continue --agent-session-id claude:<uuid>   # resume an ended Claude session
```

`claude new` starts Claude Code with an explicit `--session-id` and prints it as
the `agentSessionId` (pass `--agent-session-id ID` to choose it). Codex does not
accept a launch-bound id, so `codex new` reconciles the UUID from the exact
launched process's rollout and returns the durable `agentSessionId` on the same
window. `codex continue --agent-session-id <uuid>` resumes that conversation via
`codex resume <uuid>`, with the same already-live duplicate guard as Claude.
`continue` remains unsupported only for Gemini (resumes by index, not uuid).
The CLI is biased toward reuse —
`new` refuses to add a panel when a reusable one exists and prints its capture so
you can decide where to send next. `--cwd` is passed to the provider at creation
time and must exist in the new terminal environment (for Docker workers,
`/workspace` is the container filesystem).

For multiple machines, target a provider with `--provider <alias>` / `--url <url>`
(localhost, a remote host, or a tunnel URL); with no target the CLI auto-resolves
a local one. Auth stays off unless the provider record sets `requiresAuth`.

### Break-glass: terminals and windows (plumbing)

Below the session layer is the raw transport — terminals and board windows. You
should rarely touch it; these verbs name a `terminalId` / window id directly and
exist for diagnosis and repair:

```sh
termfleet list --url <url>                                   # board windows
termfleet capture --url <url> --terminal-id <id> --lines 40  # raw pane capture
termfleet send --url <url> --terminal-id <id> --break-glass --text "pwd" --enter  # --break-glass required for a non-agent shell
termfleet terminals move --url <url> --id 1 --left 80 --top 80 --width 900 --height 520
termfleet close --url <url> --id 1                           # by window id

# raw shell creation — repair-only, when Claude/Codex can't start or the provider needs diagnosis:
termfleet new --repair --url <url> --break-glass --cwd /workspace --command "gh auth status"
```

## MCP server

The same session-first control verbs the CLI exposes are also available over MCP,
so any MCP-capable agent (Claude Code, Claude Desktop, or your own host) can drive
the fleet — list sessions, read a transcript, reply, launch, continue, and close —
without shelling out. The `termfleet-mcp` bin is a stdio MCP server and a thin
client of the same provider resolution the CLI uses (`TERMFLEET_PROVIDER_URL` →
`termfleet use` context → live local discovery), so it adds no new behavior, just a
second front door.

Tools: `list_sessions`, `get_session` (supports `since` for catch-up and `cwd` to
resolve some transcripts), `send_to_session`, `new_session` (accepts `cwd` / `name`
/ `prompt`), `continue_session`, `close_session`. Sessions are addressed by their
prefixed `agentSessionId` (e.g. `claude:<uuid>`); window/terminal resolution happens
internally.

Register it with an MCP host, e.g. in Claude Code's config:

```json
{
  "mcpServers": {
    "termfleet": { "command": "termfleet-mcp" }
  }
}
```

`termfleet-mcp` is on PATH after a global install (`npm install -g termfleet`);
otherwise use `{ "command": "npx", "args": ["-y", "termfleet-mcp"] }`, or from a
clone `{ "command": "npx", "args": ["tsx", "<repo>/src/mcp/server.ts"] }`.

Point it at a specific provider by setting `TERMFLEET_PROVIDER_URL` in the server's
`env`; otherwise it auto-discovers a local provider on first use.

## Library: reading agent transcripts (`@termfleet/core`)

The transcript layer is usable on its own, beyond the console. Install it
standalone — a small **Node-only** library that reads and normalizes Claude Code /
Codex / Gemini sessions off disk into one model:

```sh
npm install @termfleet/core
```

```ts
import { readLocalAgentSession } from "@termfleet/core/agent-session.js";
import { parseAgentSessionId } from "@termfleet/core/agent-session-id.js"; // claude:/codex:/gemini: grammar

const session = readLocalAgentSession({ cwd: process.cwd(), sessionId: "claude:<uuid>" });
console.log(session.messages.length, session.lastAssistantText);
```

See [`packages/core/README.md`](./packages/core/README.md) for the full surface —
per-agent readers, the id grammar, the streaming tail, and the provider SDK
(`ProviderClient`, for *driving* a provider). If you already depend on
`termfleet`, the same readers are re-exported from `termfleet/sessions` as a
convenience.

The terminal substrate is owned by Supercode and independently reusable:

```sh
npm install @volter-ai-dev/supercode-terminal
```

Its explicit `substrate/*.js` subpaths separate the browser-safe WebSocket client, native CLI
attachment, tmux session/capture/input primitives, and server-side PTY streaming.
An embedder can use only the layer it needs. Closing an attachment releases its
PTY, listeners, and descriptors but never terminates the durable tmux session;
termination remains a separate host decision. `@termfleet/terminal` preserves
the seven legacy entrypoints and types as direct compatibility reexports. See
[`packages/terminal/README.md`](./packages/terminal/README.md).

## CLI Commands

### Sessions (the primary surface — tier 1)

Work by `agentSessionId`; termfleet resolves window→terminal internally. This is
what an orchestrator should use — windows and terminals (below) are plumbing.

- `sessions recent [--live] [--sort recent|attention] [--fleet] [--limit N] [--cursor C] [--console-url URL]`:
  list sessions across providers. `--sort attention` is the triage view; `--live`
  streams updates.
- `sessions search --query TEXT [--limit N] [--console-url URL]`: find sessions by content.
- `sessions watch [--interval-ms N] [--console-url URL]`: poll the session list on an interval.
- `<claude|codex|gemini> get --agent-session-id ID [--cwd PATH] [--since N]`: read a
  session's normalized transcript; `--since N` returns only items after #N.
- `<claude|codex|gemini> wait --agent-session-id ID [--after-count N] [--timeout-ms N] [--poll-ms N]`:
  block until the session's current turn ends. `--after-count N` waits for a turn
  *after* the Nth message (use it right after `send`).
- `claude continue --agent-session-id ID [--cwd PATH] [--prompt TEXT]`: resume an
  ended Claude session (`claude --resume`). **Claude only.**
- `<claude|codex> list`: list that agent's sessions on the provider.
- `<claude|codex> kill (--id ID | --name NAME)`: stop a running agent session.
- `send --agent-session-id ID --text TEXT [--enter]`: reply to a session.
- `interrupt --agent-session-id ID`: cancel the current turn without closing the session.
- `close --agent-session-id ID`: close a session.

`send` / `close` also accept `--terminal-id` / `--id` as break-glass — see the
window/terminal plumbing below.

### Provider resolution

Every command that talks to a provider accepts `--url URL` or `--provider TARGET`,
but **both are optional**. When neither is given, termfleet resolves a provider in
this order (the same model Docker/kubectl/tmux use for local defaults):

1. `--url` / `--provider` — explicit, wins over everything.
2. `$TERMFLEET_PROVIDER_URL` — the env var agents and CI use to inject a target.
3. **Current context** — a default you pin with `termfleet use <url|name>`.
4. **Live local auto-discovery** — `provider serve` self-advertises to
   `~/.termfleet/providers/`, and the CLI picks the single one that is *actually
   serving* (verified via `/healthz`, so a stale record never wins). The console
   reads the same directory on its own 5s sweep and registers what it finds as a
   bare-pointer provider — this is what makes console/provider start order not
   matter (`--no-local-discovery` opts a console out).
5. Otherwise a **structured error** (`no_provider` or `ambiguous_provider`) with a
   `remedy` field telling you how to start or pick one.

`~/.termfleet` (the advertisement dir in step 4, plus the `termfleet use` context
file) is termfleet's own machine-global state — override its root with
`TERMFLEET_HOME` to isolate it (throwaway stacks, tests, or running two unrelated
termfleet setups on one machine without them seeing each other's providers/context).

This means `termfleet claude list`, `termfleet claude new …`, etc. just work when
one provider is running locally — no URL required. Helpers:

- `termfleet resolve [--url URL | --provider TARGET]` — print which provider would
  be chosen and **why** (the `source` field), read-only. Use this instead of
  guessing when a command can't find a provider.
- `termfleet use <url|name>` — pin a default provider; `use --show` / `use --clear`
  to inspect or unset it. Agents should prefer `$TERMFLEET_PROVIDER_URL` (which
  outranks the pinned default) so they never depend on shared mutable state.
- `termfleet doctor` — now also reports live local providers and what the default
  would resolve to.

Resolution failures are emitted as JSON on stderr with a stable `error` code and a
`remedy`, and always exit nonzero — so an agent can branch on the cause rather
than parse prose.

- SDK: import `ProviderClient` and `providerRefFromUrl` from `termfleet`.
  Console setup methods include `signInToRegistry`, `registrySession`,
  `switchRegistryOrganization`, `registryProviders`, `startDockerWorker`,
  `startLocalProvider`, `registerLocalProvider`, `unregisterLocalProvider`,
  `registerSharedProvider`, `unregisterSharedProvider`, and
  `signOutOfRegistry`.
- `providers health --url URL`: prints provider health.
- `providers snapshot --url URL`: prints display bounds and terminal windows.
- `providers lifecycle --url URL`: prints observed process/session lifecycle.
- Provider targets passed with `--provider` can be a saved alias, provider id,
  exact label, or URL. Save aliases during registration with `--alias` or
  `--aliases`, or update an existing provider with `providers alias set`.
- `registry login [--console-url URL] --identifier EMAIL (--password PASSWORD | --password-stdin)`:
  signs in to the shared registry through the console SDK.
- `registry logout [--console-url URL]`: clears the saved CLI registry session.
- `registry session [--console-url URL]`: prints the current registry account and
  active organization.
- `registry list [--console-url URL]`: lists providers visible to the local
  console, including shared providers when signed in.
- `registry start-docker-worker [--console-url URL] [--url URL] [--label LABEL] [--alias ALIAS] [--aliases A,B] [--terminal-theme-profile PROFILE]`: asks
  the console to start the default Docker worker provider.
- `registry register-local [--console-url URL] --url URL [--label LABEL] [--alias ALIAS] [--aliases A,B] [--requires-auth]`: stores
  a local/loopback provider in the console-local registry.
- `registry register-shared [--console-url URL] --url URL [--label LABEL] [--alias ALIAS] [--aliases A,B] [--requires-auth]`:
  registers a reachable provider URL in the active organization. Use
  `--requires-auth` for public or tunneled providers.
- `registry service-clients create --registry-url URL --label LABEL --organization ID_OR_SLUG --provider-origins URL[,URL] --scopes SCOPE[,SCOPE] --credential-file ABSOLUTE_PATH`:
  creates a Registry Worker provider-token client through the separately
  configured infrastructure-operator credential. The generated client
  credential is written once to a new `0600` file and is never printed.
- `registry service-clients list --registry-url URL`: lists only public client
  metadata; stored credential hashes and generated credentials are never
  returned.
- `registry service-clients revoke --registry-url URL --client-id ID`: revokes
  one client without rotating or replacing any other client.
- `registry unregister-shared [--console-url URL] (--provider TARGET | --url URL)`: unregisters a shared
  provider URL from the active registry organization.
- `providers alias list`: lists saved provider aliases.
- `providers alias set (--provider TARGET | --url URL) --alias ALIAS`: saves an alias
  for an existing provider.
- `providers alias unset (--provider TARGET | --url URL) --alias ALIAS`: removes an
  alias from an existing provider.
- `list --url URL`: lists provider-owned terminals.
- `capture --url URL --terminal-id TERMINAL_ID [--lines N]`: captures terminal output.
- `send --url URL --terminal-id TERMINAL_ID --text TEXT [--enter]`: sends input to a
  provider-owned agent terminal.
- `close --url URL --id ID`: closes a provider-owned terminal window.
- `claude new --url URL [--name NAME] [--cwd PATH] [--prompt TEXT]`: creates an agent terminal and starts
  `claude --dangerously-skip-permissions`.
- `codex new --url URL [--name NAME] [--cwd PATH] [--prompt TEXT]`: creates an agent terminal and starts
  `codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust`.
- `terminals move --url URL --id ID --left N --top N --width N --height N`: moves
  a provider-owned terminal window.
- `terminals resize-display --url URL --width N --height N`: resizes a virtual
  provider display.
- `new --repair --url URL --break-glass [--name NAME] [--cwd PATH] [--command COMMAND]`:
  creates a raw provider-owned shell for repair use only. Prefer `claude` or
  `codex` for normal work.
- `console serve --name NAME [--port PORT] [--host HOST] [--local-adapters iterm[:PORT],wezterm[:PORT]|none] [--no-auto-local-adapters] [--no-local-discovery] [--tunnel] [--tunnel-id ID]`:
  starts the browser console server. `--tunnel` publishes it at a stable public
  URL (see "Small Public Deployment" and `SECURITY.md`); `--local-adapters` picks
  the always-on iTerm/WezTerm mirrors; the console pulls local-provider discovery
  on a 5s sweep by default (`--no-local-discovery` opts out).
- `console rotate-passcode [--name NAME]`: rotates the 6-digit tunnel access
  passcode (new code, same signing secret — existing sessions survive); takes
  effect on the next console start.
- `provider serve --kind iterm --name NAME [--port PORT] [--host HOST]`: starts
  an iTerm-backed provider server.
- `provider serve --kind virtual-tmux [--prefix PREFIX] [--count N] [--port PORT] [--host HOST]`:
  starts a virtual tmux provider server. `--count` defaults to `0`.
- `provider serve --kind wezterm [--prefix PREFIX] [--count N] [--port PORT] [--host HOST]`:
  starts an experimental WezTerm provider server. `--count` defaults to `0`.
- `provider serve --kind browser --name NAME [--port PORT] [--host HOST]`: starts a
  browser provider — an embedded [lucarne](https://www.npmjs.com/package/lucarne)
  engine (real Chrome over CDP). Each browser session is a **porthole** window you
  can watch, drive, and take over, the browser-world peer of a terminal window.
  Open / list / close them with `browser new|list|close` (needs Google Chrome).
- `provider serve --kind cdp --name NAME [--port PORT] [--host HOST]`: starts a
  CDP **mirror** provider — portholes for browsers termfleet did not launch. It
  attaches to Chrome DevTools endpoints on loopback (mirrors, never owns: closing
  the porthole never closes the browser). Paired with the virtual-tmux provider
  it makes **agent-launched Playwright browsers appear automatically**: every
  pane the tmux provider creates carries a Node preload hook (appended to
  `NODE_OPTIONS`, fail-open, opt-out with `TERMFLEET_NO_CDP=1`) that spots a
  Playwright Chromium launch, adds `--remote-debugging-port=0`, and announces the
  browser — attributed to its fleet session — under `~/.termfleet/cdp-announced/`.
  The mirror surfaces each announced browser as a `<session> · <page title>`
  porthole and drops it when the browser exits. `TERMFLEET_CDP_ANNOUNCED_ONLY=1`
  restricts the mirror to announced browsers only (no default-port probing, no
  process scanning) — the board shows agents' browsers and nothing else. Note the
  hook modifies agent pane environments and each mirrored browser serves an
  unauthenticated CDP port on `127.0.0.1` — see `SECURITY.md`.
- Any `provider serve --console-url URL` (or `$TERMFLEET_CONSOLE_URL`) makes the
  provider push-announce itself to that console — **opt-in only**: a provider no
  longer announces to `http://127.0.0.1:7373` implicitly (migration note: if you
  relied on that implicit default, pass `--console-url` explicitly now). This is
  rarely needed locally since the console already pull-discovers a same-`TERMFLEET_HOME`
  provider on its own; it remains useful for a console on another machine or HOME.
- `registry start-docker-worker [--console-url URL]`: asks the console to start
  the Docker worker and register its provider URL.

### Driving a session from your terminal

The console mirrors every session in the browser, but you don't have to leave the
terminal to type into one. Two ideas, kept lexically separate so they never blur
into each other — **create** (boot a session) vs **connect** (attach to a live one):

- `attach [SESSION | --agent-session-id ID]`: connect the calling terminal
  directly to a live session — the raw PTY, the same stream the browser shows. With
  no argument it attaches to the single live session; otherwise pass an
  `agentSessionId` (or a prefix / window name). Detach with **Ctrl-b d** — the
  session keeps running (it's durable on disk and reaped independently); only an
  explicit exit ends it. `attach` never boots anything.
- `control` (alias `ctl`): **the front door** — a fresh orchestrator chat in your
  **fleet-control folder** (`~/fleet-control` by default; override with `--cwd`,
  persist with `--save`) using the default agent, and because you ran it from a
  terminal it **attaches by default**. `--no-attach` for a scriptable boot. It
  **tunnels by default**; pass `--no-tunnel` for a local-only console. This is the
  one-word front door to driving the fleet from your own shell.
- `chat --attach` (**deprecated — use `ctl`**): the normal `chat` bring-up
  (console + fleet + a launched agent), but instead of printing JSON it drops you
  straight into the new session. Bare `chat` still reports JSON and exits, so
  scripts are unaffected — `chat` remains for agentless boot, an arbitrary `--cwd`,
  and JSON-report scripting, and prints a deprecation notice each run.

```sh
termfleet control            # the front door — boot + attach the orchestrator, here, now
termfleet ctl --cwd ~/fleet-control --save   # remember the folder for next time
termfleet ctl --no-tunnel    # local-only console
termfleet chat --attach --cwd ~/myproject    # (deprecated — use ctl) boot a chat here and stay attached
termfleet attach             # reconnect to the one live session
termfleet attach claude      # reconnect to the most-recent claude session
```

## Testing

The suite (`test/*.test.js`, run via `tsx --test`) is split into four explicit
tiers. The tier→file mapping lives in one manifest,
[`test/test-tiers.mjs`](test/test-tiers.mjs). Every file must be listed exactly
once; missing, stale, and duplicate entries fail before any test starts.

| Tier | `npm run` | Needs | Runs in CI |
|---|---|---|---|
| `fast` | `test:fast` (`test:unit` compatibility alias) | Pure deterministic logic; no subprocess or real server launches | ✅ |
| `integration` | `test:integration` | Process/server tests and slower proofs; some need tmux, Chrome/Chromium, or workerd | — |
| `host` | `test:host` | A real macOS terminal host app (iTerm2, WezTerm, Terminal.app) driven via AppleScript/native APIs | — |
| `agents` | `test:agents` | An actually installed, logged-in agent CLI (`claude`/`codex`/`gemini`/`supercode`) invoked as a real process (TF-105) | — |

`npm test` runs only the fast lane. It has an enforced 10-second total budget
and a runtime guard that rejects subprocess and real
network-server launches. Use `npm run test:all` for the former full portable
gate (`test:fast` + `test:integration`). `test:host` is currently empty; the
credential-gated `test:agents` lane contains the opt-in real-agent canary. Most
tests whose names mention
iterm/wezterm/terminal-app or an agent CLI turn out, on inspection, to test
pure logic against fixtures/stubs (e.g. `iterm-reap-ended.test.js` exercises
only the pure `isEndedAgentReapable()` predicate; every `agent-launch-*`/
`agent-continue-*` test drives a *fake* `claude` shell stub over a real,
hermetic tmux server, never a real credentialed CLI). The real
macOS/credentialed-CLI proofs that DO
exist today are gated **manual** scripts outside the `test/*.test.js` glob, run
on demand, not part of any tier: `npm run test:orchestrate` / `npm run
test:fleet` (real Claude/Codex/Gemini sessions against a real console) and
`test/answer-question-live.mts` / `test/terminal-app-serve-live.mjs` (see their
header comments for invocation).

`test/*.test.js` also excludes `test/e2e/*.spec.js` (Playwright specs, run
separately by `npm run test:e2e`) and the `test/fixtures/`/`test/helpers/`
support directories.

**On test concurrency (per tier, and per phase):** the tiers no longer run
`--test-concurrency=1` across the board — that was a stopgap for a real hazard,
now removed by making every server-spawning test hermetic. The hazard: tmux
hermeticity is per-test (`tmux -L <socket>`), so parallel files can't collide on
a tmux server — but several files also spawned a real console/registry/provider
on a **hardcoded literal TCP port** that another file hardcoded too (e.g. 7497:
`collab-sync`/`console-build-version`/`console-port-conflict`; and across the
integration tier: 7433, 7475, …). Two such files run concurrently **hang** on
`EADDRINUSE` rather than failing fast — worse than a flaky failure, since a hang
burns the whole CI timeout. The fix was to move every fixed test port to an
ephemeral OS-assigned allocation (`test/helpers/free-port.mjs` `freePort()`, or
an in-process `listen(0)` read back). With that done:

- **fast** runs at node:test's default concurrency (= CPU count), inside its
  hard runtime and wall-clock budgets.
- **integration** runs in two phases: a **concurrent** phase (default
  `--test-concurrency=4`, `TEST_CONCURRENCY` overrides) for the parallel-safe
  majority, then a short **serial** phase (`--test-concurrency=1`) for files that
  measure a *process-global* or *wall-clock* quantity a co-running peer perturbs,
  or contend for a shared real resource: the `/dev/ptmx` fd-count leak proofs
  (`terminal-pty-release`, `terminal-ws-leak-e2e` — one also mutates
  `process.env.TERMFLEET_TMUX_SOCKET`), the provider event-loop heartbeat/timing
  SLAs (`virtual-tmux-event-loop`, `virtual-tmux-capture-event-loop`), and the
  real-Chrome/CDP mirror tests (`cdp-mirror`, `cdp-clean-boot`, `browser-driver`,
  `browser-tunnel` — two real headless Chromes racing over CDP on a 2-core runner
  starve each other's DOM capture). Moderate, not full-CPU: parallelizing real
  tmux/Chrome/workerd too aggressively thrashes and flakes. This cut the
  integration tier from ~237s serial to ~100s.
- **host/agents** stay serial (empty / gated).

The wall-clock tier watchdog + `--test-force-exit` + `--test-timeout` span every
phase and remain the hang safety net (see `scripts/run-test-tier.mjs`).
Playwright e2e runs separately via `npm run test:e2e`.

## Design

`tmux` owns terminal sessions because it has stable IDs, reliable input/output
commands, capture APIs, and session persistence.

Provider instances own terminal/window/display state. The console owns board
layout. Local and remote registries provide discovery and remote reachability
without becoming the authority for terminal state.

Transport split:

- HTTP: health, bootstrap, debug, terminal capture.
- Socket.IO: provider commands, acknowledgements, snapshots.
- Raw WebSocket: live terminal PTY streams for the browser UI.

## License

[Apache-2.0](./LICENSE). The standalone library is published as
[`@termfleet/core`](https://www.npmjs.com/package/@termfleet/core); the CLI +
console as [`termfleet`](https://www.npmjs.com/package/termfleet).
