# Terminal

> OpenEng Terminal reference: a browser-driven, refresh-resilient local shell — sealed loopback engine, groups/tabs, run blocks, PTY, learned suggestions, SSH, vaults, and cloud connectors.

OpenEng Terminal is a real login shell that runs on your own machine, driven from a browser at `terminal.openeng.app`. The web app is a thin client: it renders command blocks and streams keystrokes, while every PTY, every byte of output, and every stored credential lives in a local engine bound to `127.0.0.1:50052`. The two halves talk over a gRPC channel that is encrypted on the loopback connection — each frame individually sealed — so nothing about your terminal leaves the device except what you send to a remote host yourself, over [SSH](#ssh-and-vaults) or a [cloud connector](#connectors). The engine keeps processes alive across page reloads, so a refresh, a second tab, or even a browser restart reattaches to running commands instead of losing them.

## Overview

The product is split across two artifacts that ship and update independently:

- **The engine (local)** — A native Rust binary — `openeng-terminal` — that owns the PTYs, the block model, command history, the learned suggestion model, the SSH manager, and the encrypted secret vault. It binds loopback only and self-updates at boot. One engine per machine, enforced by a PID lock.
- **The web app (browser)** — A React surface hosted at `terminal.openeng.app`. It negotiates a sealed session, lists groups/tabs, renders blocks and raw shell output, and drives suggestions — but never executes anything itself. All state that matters is engine-owned, so every browser on the same machine sees identical data.

Because the shell is a persistent local process rather than a browser sandbox, the Terminal behaves like a native app: `cd` persists, environment variables persist, background commands keep running, and interactive full-screen programs (`vim`, `htop`, `less`, a REPL) work as they do in a native terminal.

> [!NOTE]
> The engine coexists with the OpenEng AI engine. It roots its state at `~/.openeng/terminal/` and binds port `50052`, keeping a completely separate lock, endpoint, and token from the AI engine (`~/.openeng/runtime/`), which binds no local port at all: it connects out to the OpenEng Broker instead.

## Install and pair the engine

Terminal is free. It is one of the five engineering apps on the OpenEng platform and shares your OpenEng sign-in, but it runs on its own engine, separate from the OpenEng AI engine. Terminal runs real commands; it does not chat, plan, or run agents. For agents on your own hardware, see [OpenEng AI](ai.md).

1. **Install the engine.** On Linux (x86_64) or macOS (Apple silicon or Intel; for now the macOS build is the previous release, see [Platforms](install.md#platforms)), run:

   ```bash
   curl -fsSL https://openeng.app/engine/terminal/install.sh | bash
   ```

   The installer downloads the binary for your platform, checks it against its published SHA-256 checksum when it can (it prints `checksum verified`; see [Check a download yourself](install.md#verify-a-download)), and installs `openeng-terminal` into `~/.local/bin` (set `OPENENG_BIN_DIR` to use another directory). If that directory is not on your `PATH`, it adds it to your shell startup files. On macOS it also clears the download quarantine flag, so Gatekeeper does not prompt. There is no native Windows build; on Windows, use WSL.
2. **Sign in.** Open [terminal.openeng.app](https://terminal.openeng.app) and sign in with your Google account (the sign-in runs through `auth.openeng.app`).
3. **Start the engine.** Until an engine is reachable, the app keeps you on its pairing screen (`/pair`), which shows a start command with a one-time key, ready to copy:

   ```bash
   openeng-terminal serve --oauth=<one-time key>
   ```

   Run it on the same machine. The app connects as soon as the engine is up (or when you click **Retry connection**) and takes you to the page you originally opened.

> **Note:** The one-time key expires after about five minutes. Refreshing the pairing screen reuses a key that is still valid; **Regenerate** mints a new one. The OpenEng API mints at most five unused keys an hour per account, shared by all OpenEng apps (a key an engine has used no longer counts), and each app also counts the keys it mints in your browser. [Install, platforms and updates](install.md) covers updating and uninstalling, and [Getting started](getting-started.md#install-the-engine) shows the flow for every app.

## Using the app

This section walks through the workspace at `terminal.openeng.app`. The sections after it are the reference for what the engine does underneath.

### The workspace

- **Left menu:** **Terminal**, **Saved**, **History**, **SSH**, **Parallel execution**, and **Compare blocks**, with **Settings** at the bottom. Each item has its own URL (`/terminal`, `/saved`, `/history`, `/ssh`, `/parallel`, `/compare`, `/settings`), so a refresh, a bookmark, or the browser's back and forward buttons land on the same section; any other path opens `/terminal`. Clicking the section you are already in hides its side panel. **Collapse**, at the bottom of the menu, shrinks the menu to icons, and the app remembers that choice.
- **Header:** a button that hides or shows the side panel, the **group switcher** (every group, with a check on the active one, a disabled **Cloud Terminal** row marked "soon", and a **Settings** shortcut), a status dot for the engine (hover it to see whether it is online, connecting, or offline), a search button for the command palette, and your account menu.
- **Main area:** one tab strip holds every kind of tab (terminals, SSH sessions, parallel runs, comparisons, and imported read-only views), each with its own icon.

### Groups and tabs

- The **Terminal** section's side panel lists your groups as cards. Create a group with **New group** (the button at the top of the panel, the dashed card, or **Settings → Groups**). The dialog asks for a **Group name** and, optionally, a **Working directory** (type it, or click **Select** to use your system's folder picker), a **Visibility** (Normal or Incognito), **Environment Variables**, **Aliases**, and a **Start script**. A new group becomes the active group and gets its first terminal automatically. [Groups](#groups) explains each setting.
- Switch groups from the side panel, the header's group switcher, or **Settings → Groups**. Switching into a group that has no tabs opens a new terminal in it. Edit a group with **Configure group**. **Delete group** is disabled for your last remaining group, and deleting the active group switches you to the next one.
- **New terminal** (the **+** at the end of the tab strip) opens a terminal in the active group. Double-click a tab's title to rename it: <kbd>Enter</kbd> saves and <kbd>Esc</kbd> cancels. Custom names survive a refresh. The **×** on a tab closes it.

### Running commands

- Type a command in the bar at the bottom and press <kbd>Enter</kbd>. <kbd>Shift</kbd>+<kbd>Enter</kbd> starts a new line, for multi-line commands and heredocs. Each command becomes a block with its output and a status footer; see [the run-block model](#run-blocks).
- As you type, a suggestion list opens above the bar, with **SUGGESTED**, **IN THIS FOLDER**, and **RECENT** rows, plus a pinned **RECOVER** row after a command fails ([Command suggestions](#suggestions) explains where they come from). On an empty bar, <kbd>↑</kbd> or <kbd>↓</kbd> browses your history. <kbd>→</kbd> (with the cursor at the end of the line) or <kbd>Tab</kbd> copies the highlighted row into the bar without running it, <kbd>Enter</kbd> runs, and <kbd>Esc</kbd> closes the list.
- While a command runs, a bar above the command bar offers **Stop** and a field that sends text to the command's standard input. <kbd>Ctrl</kbd>+<kbd>Enter</kbd> (or <kbd>⌘</kbd>+<kbd>Enter</kbd>) in the command bar sends input too. A stopped command's block is marked `killed`.
- Editors, pagers, monitors, and interactive sessions (`vim`, `less`, `top`, `tmux`, a bare `python` or `psql`, an interactive `ssh`, `kubectl exec -it`, and similar) open in a live terminal pane that takes over the tab. When you close the pane, what was on screen is kept as a finished block.
- Commands that keep running, such as `tail -f` or `npm run dev`, switch the tab to shell view while they run and switch back to blocks when they stop.

### Working with blocks

- Each finished block has a row of buttons in its header: **Copy input**, **Copy output**, **Save as command**, **Save as template**, and **Find / filter in output**, which narrows that block's output to matching lines and shows how many matched. A long command collapses to one line with an expand arrow, and a long output gets its own scroll-to-top and scroll-to-bottom buttons. A failed block shows its `exit` code, a stopped one shows `killed`, and a block created by a parallel run shows a `parallel` badge.
- Select text in a block's output and right-click it to get **Copy** and **Run with template**. **Run with template** lists your saved templates; picking one puts that template into the command bar with your selection in place of its `::VALUE::` placeholder. Nothing runs until you press <kbd>Enter</kbd>.
- The toolbar above the blocks shows the tab's environment as `KEY=value` chips (the group's variables, overridden by the tab's own) and has **Go to start**, **Go to bottom**, **Search / filter**, and **Clear blocks**. **Search / filter** works across every block in the tab: search highlights matches and steps through them with <kbd>Enter</kbd> and <kbd>Shift</kbd>+<kbd>Enter</kbd>, and filter hides every block and line that does not match.
- The button at the right end of the command bar switches the tab between block view and shell view (the same session drawn two ways; see [the run-block model](#run-blocks)). The choice you make sticks across refreshes. You cannot switch while a command is running.

### Saved commands and templates

**Save as command** stores a block's exact command. **Save as template** opens a dialog where you name the template and replace the part that changes with the placeholder `::VALUE::`; **Save template** stays disabled until the template has a name and at least one placeholder. Both kinds appear in the **Saved** section (`/saved`), under **Commands** and **Templates**. From there you can put any entry into the command bar (it never runs by itself), edit it, or delete it. The library is stored by your engine, so every browser on the machine sees the same entries.

### History

The **History** section (`/history`) lists every command you have run, newest first and without duplicates: the commands the engine recorded, and, marked separately, the login-shell history you already had before you installed the engine (see [History](#history)). Type to filter the list, and click a row to put that command into the command bar (it never runs by itself). <kbd>Esc</kbd> closes the panel.

### Parallel execution

**Parallel execution** (`/parallel`) runs one command in two terminals at the same time. In the side panel, tick exactly two open terminals (the active one starts ticked, and a counter shows how many of the two you have picked), then click **Start**. A parallel tab opens: type the command there and click **Run** (then **Run again** for the next command). Both outputs stream live, one above the other, each as a normal block with its own stop and input controls. Each block is also added to its own terminal, marked `parallel`.

### Compare blocks

**Compare blocks** (`/compare`) shows the difference between the outputs of two commands. Tick two open terminals and click **Compare**. In the compare tab, pick a finished block on each side (each side starts on that terminal's newest finished block) to see a line-by-line diff, with added, changed, and removed lines tinted and a count of each. **Swap sides** flips them. The diff is computed in your browser.

### Export and open tabs

Each tab in the strip has an **Export tab** button that downloads it as a JSON file named `openeng-<type>-<title>.json`. A terminal or parallel tab exports its blocks, a compare tab exports its diff, and an SSH tab exports the text on screen. **Open an exported tab** (the folder button at the end of the tab strip) opens such a file as a read-only view tab, labeled as imported, with no command bar. Export and import happen entirely in your browser; the engine is not involved.

### SSH hosts and cloud connectors

The **SSH** section (`/ssh`) lists your saved connections, with buttons to refresh the list, edit a connection, or delete it. **New connection** opens a form with two connection types:

- **SSH host:** a label, username, host, and port, and an auth method: **SSH Key** (a path to a key file) or **Password** (you are prompted inside the session, and nothing is stored). **Save & open** opens the host in an SSH tab, a live terminal that runs your own `ssh` client (see [Brokered SSH](#brokered-ssh)).
- **Cloud connector:** a label, the connector URL, its API key, an optional authorization token, and an optional write key. Opening it shows a console where you send commands such as `LIST`, `RUN echo hello`, or `STATUS` to the connector, with an optional **target** field and <kbd>↑</kbd>/<kbd>↓</kbd> to recall earlier commands (see [Cloud connectors](#connectors)).

The engine caches this list, so it appears instantly and refreshes in the background.

### Settings

**Settings** (`/settings`, or the last row of the group switcher) takes over the main area. Its categories:

| Category | What it does |
| --- | --- |
| **Groups** | Every group, with switch, configure, and delete, plus **New group**. |
| **High persistency** | Turns on the engine's per-terminal command log and sets the folder it writes to. **View previous tabs** opens a read-only viewer of the saved logs; see [High persistency](#high-persistency-opt-in). |
| **SSH keys** | Named references to key files on disk: a name and a path, never the key itself. |
| **Credentials** | The encrypted vault: add a named value, replace it, or delete it. The list shows names only, and you use a value in a command as `@name`; see [the vault](#the-encrypted-secret-vault-name). |
| **Cloud Terminal** | Disabled and marked "soon". |

### Command palette

<kbd>⌘</kbd>+<kbd>K</kbd> (<kbd>Ctrl</kbd>+<kbd>K</kbd> on Windows and Linux), or the search button in the header, opens a palette. From it you can run what you typed as a command, open a new terminal in the active group, jump to any group or terminal, or pick one of your 30 most recent commands. Use <kbd>↑</kbd>, <kbd>↓</kbd>, and <kbd>Enter</kbd> to choose; <kbd>Esc</kbd> closes it.

### When the engine is offline

If the engine stops answering, the header's status dot shows it. If you open or refresh a page while the engine is down, the app shows the pairing screen and then returns you to the same page as soon as the engine is back, without a reload. Panels that the engine caches, such as the SSH list, keep showing their last data while the app reconnects, and suggestions fall back to your local history. When a newer engine version has been downloaded, a banner offers to restart the engine; see [Mandatory auto-update](#mandatory-auto-update).

The account menu (your avatar, at the right of the header) shows your email, the engine and app versions, a **Release notes** link, and **Sign out**.

## Architecture & the sealed connection

The single boundary between the browser and the shell is one gRPC service, `openeng.terminal.v1.Terminal`, transcoded to gRPC-Web on the loopback socket. Two independent layers protect it.

### TLS on loopback with a publicly-trusted name

The engine binds `127.0.0.1:50052` but serves TLS for the host `engine.openeng.app`. That name is a global DNS A-record pointing at `127.0.0.1`, so a publicly-trusted (Let's Encrypt) certificate for it validates in every browser — Chrome and Safari — with no manual trust step, while the socket never leaves loopback. HTTPS is mandatory: Safari refuses plain-http loopback as mixed content. Every signed-in engine receives the same certificate and private key, so the certificate proves the name, not which engine answers; the name resolving to `127.0.0.1` is what keeps the connection on your machine.

### Per-frame sealing

`CreateSession` is the only unsealed RPC — it bootstraps the seal via an X25519 key exchange (the client and engine each send a base64 public key). Every other RPC is additionally sealed per-frame with ChaCha20, so even the loopback payload is encrypted on the wire. Sealing applies only to a session whose client offered a public key at `CreateSession` (a session opened without one isn't sealed), a request frame that fails to unseal is passed on as it arrived, and the key exchange itself isn't authenticated. The handshake also returns a protocol-version pair (`major`/`minor`) plus a `compatible` flag and a `mismatch_reason`, so a client and engine that drift apart surface a clear "update your client/engine" message rather than failing silently.

### Origin and Host lockdown (drive-by defense)

A loopback engine is reachable by any web page, so the gRPC-Web layer refuses browser requests that don't come from the real app. This lockdown is in the current Linux build; the macOS builds are older releases pending a rebuild and don't enforce the exact-origin allow-list yet. The current Linux build never emits a wildcard `Access-Control-Allow-Origin: *`; it reflects the ACAO header only for exact allow-listed origins, and it validates the request Host to defeat DNS rebinding.

> [!WARNING] Builds without the allow-list answer any website
> An engine build without the allow-list answers every web origin with a wildcard `Access-Control-Allow-Origin: *`, so any website you visit while it runs can fetch the handshake token and drive your shell. On macOS, don't browse untrusted sites while the engine runs, or run the engine on a Linux machine until the macOS rebuild ships.

```
Allowed browser origins : https://terminal.openeng.app, https://terminal-75b6c.web.app,
                          https://terminal-75b6c.firebaseapp.com
                          (the app's own host and its Firebase default domains; loopback dev origins only in debug builds)
Allowed Host/authority  : engine.openeng.app, localhost, 127.0.0.1, ::1
Private Network Access  : preflight granted for the public app → loopback engine (Chrome 130+)
```

A request whose Host is an attacker domain rebound to `127.0.0.1` is rejected outright; a browser request from a non-allow-listed origin gets no ACAO and is blocked by the browser. Native/curl loopback callers (no `Origin`) are permitted, including at the handshake-token endpoint (`GET /openeng/handshake`), so any program running on the machine, under any user account, can fetch the token and drive the engine.

### Boot & pairing

OAuth is the only supported boot path. The signed-in browser mints a short-lived one-time key (\~5 min TTL, cache-first, rate-limited) and shows a copy-paste start command:

```bash
openeng-terminal serve --oauth=<one-time key>
```

The engine advertises its endpoint only after it binds; the app polls, then connects the moment it is up and returns you to the deep link you originally aimed at. `CreateSession` returns a `boot_id` minted once per engine process. The browser caches its restored command-output blocks under that id: a matching `boot_id` on refresh means the same engine boot (restore the blocks); a changed/absent one means the engine restarted (the blocks are stale and discarded — only persisted groups survive a restart).

### Mandatory auto-update

The engine self-updates at boot and cannot skip it: it polls `releases.openeng.app/v1/<product>/stable/latest` (the current macOS builds, older releases, still poll the release service's former address), downloads the platform artifact, verifies its published `sha256:` checksum, and atomically swaps. While serving, a newer stable is staged in the background and reported via `GetUpdateStatus` (also surfaced on `CreateSession` as `update_available` + `staged_version`). The UI shows a restart banner; `RestartEngine` re-execs in place, carrying the live session internally so you never re-pair. The one exception, from the release notes: restarting this way when updating from Terminal 0.0.3 asks for a fresh one-time key once; updating at the next start needs nothing.

## Groups

A **group** is an engine-owned, persisted named collection of tabs that defines a shared shell context. Every tab in a group inherits the group's settings, so a group is effectively a reusable workspace profile.

| Field | Purpose |
| --- | --- |
| `name` | Display label for the group. |
| `env` | Environment variables (`name → value`) injected into every tab's shell in the group. |
| `aliases` | Shell aliases (`name → value`) applied before each command runs in the group's tabs. |
| `init_script` | A script run when a tab's shell starts, for setup that env/aliases can't express (activating a virtualenv, sourcing an rc, exporting a computed value). |
| `start_directory` | The default working directory for new tabs created in the group. |
| `incognito` | When set, the group lives in memory only — nothing touches disk, and no history/suggestion learning is recorded for its tabs. |

Groups are managed via `ListGroups`, `CreateGroup`, `UpdateGroup`, and `DeleteGroup`. A normal group persists under the engine's state dir; an incognito group is created and torn down entirely in RAM.

> [!TIP]
> Put project-wide setup (a `KUBECONFIG` path, an `AWS_PROFILE`, a `source .venv/bin/activate`) on the group. Every tab you open under it starts pre-configured, and you never leak that config into unrelated work.

## Tabs

A **tab** is a single shell context — one persistent login shell with its own working directory. Tabs belong to a group and merge their own settings over it.

- **Per-tab `cwd`** — each tab tracks its own directory; a `cd` updates only that tab.
- **Per-tab `env`** — merged *over* the group's env, so the tab wins on any key collision. The effective environment is `group.env` extended by `tab.env`.
- **Per-tab `shell`** — the resolved login shell for that tab.
- **Incognito** — a tab can be incognito (or inherit it from an incognito group). Incognito tabs record no history, learn no patterns, and are excluded from high-persistency logging and folder history.

New tabs start in the group's `start_directory` when set, otherwise the request's `cwd`, otherwise the user's home. Tabs are managed via `ListTabs`, `CreateTab`, `RenameTab`, and `CloseTab`. The engine keeps a tab's shell process alive independent of the browser, which is what makes reload survive (see below).

## The run-block model

The Terminal's execution model is **"one data, two renderings."** A single persistent login shell per tab is the sole source of truth, and it is exposed simultaneously two ways over the same session:

- **Block mode (cards)** — Each command becomes a Warp-style card: the command line, its styled output, and a status footer (exit code, duration, timestamps). Runs stream as one `BlockStarted` → `OutputDelta*` → `BlockDone`.
- **Shell mode (raw)** — A raw xterm view of the exact same session's byte stream. Toggling between block and shell is instant and lossless because both render the identical underlying `Block[]` — shell mode just paints the blocks as one continuous scroll.

### Running a command

`Run` is a server-streaming RPC: one command in, a stream of styled output deltas, then a terminal `BlockDone`. Output is delivered as `OutputDelta` events that replace lines starting at a given index — so a `\r` progress bar overwrites its previous line rather than piling up. A `cd` reports its resolved directory in `BlockDone.new_cwd` so the tab's cwd stays in sync.

Out-of-band controls ride unary RPCs so they work even mid-run:

- `WriteStdin` — send input to a running block (answer a `sudo` prompt, feed a REPL). The UI binds this to `Ctrl+Enter` for a running command.
- `Kill` — terminate a running block.

### Refresh-resilience with `AttachBlock`

The engine keeps a block's process alive across UI reconnects. On reload, the browser calls `AttachBlock` for every block it had persisted as still running. The engine re-subscribes it to the live `OutputDelta` stream and the eventual `BlockDone`; if the block already finished, it emits a single `BlockDone(status="ended")` so the UI clears its spinner. There is only a small log gap during the refresh window itself.

### Free-typed commands become blocks (OSC 633)

The persistent shell is booted with per-engine rc files (stored `0700`/`0600` under the state dir) that inject OSC 633 prompt hooks. That means even a command you type directly into shell mode is captured as a first-class block, not just raw bytes. The shell-core RPCs expose this directly:

```
OpenTabStream  → ensure the tab's shell exists, REPLAY its blocks
                 (BlockStarted + OutputDelta + BlockDone each), emit
                 TabSnapshotEnd, then stream the live tail as BOTH raw
                 PtyOutput AND parsed block events
RunInTab       → write an OSC-633-wrapped command (captured as a block)
WriteTab       → write raw keystrokes (shell-mode typing)
ResizeTab      → resize the tab PTY
```

> [!NOTE]
> `OpenTabStream`/`RunInTab`/`WriteTab`/`ResizeTab` are the Stage-2 shell-core surface. Alongside them, the simpler `Run`/`AttachBlock` block path is the reliable, shipped model the UI renders today.

## Interactive PTY

Full-screen and interactive programs need raw PTY semantics, not parsed blocks. `OpenInteractive` spawns a program (or, with an empty `command`, a login+interactive shell) inside a tab and server-streams raw PTY bytes as `PtyEvent`s (`PtyStarted` → `PtyOutput*` → `PtyDone`) which xterm.js renders directly.

Because gRPC-Web can't open a bidirectional stream, the inbound leg is unary:

| RPC | Role |
| --- | --- |
| `OpenInteractive` | Start the program; server-streams raw PTY output. |
| `WriteInteractive` | Send a keystroke/byte chunk to the PTY (serialized per-keystroke for correct ordering). |
| `ResizeInteractive` | Propagate a terminal resize (cols/rows). |
| `KillInteractive` | Terminate the interactive session. |

Nagle is disabled on the loopback connection so interactive output streams with minimal latency. The same PtyEvent transport powers brokered SSH (below).

## Command suggestions

Suggestions are served by one fast unary RPC, `Suggest` (partial command text, cwd, tab id in; three ranked lists out). Everything runs fully on-device — there is no cloud suggestion tier. Under the hood is an **outcome-conditioned learned pattern engine**: the model conditions not just on what you ran, but on what happened when you ran it.

### The tiers

Results are assembled with strict cross-tier de-dup in priority order `RECOVER > RECENT > FOLDER > AI`:

- **RECOVER (pinned)** — When the tab's last command *failed*, the fixes you run to recover from that failure class are pinned to the top. Binary-scoped recoveries outrank cross-command ones. On a first-ever failure with no learned fix, a bundled corpus seed for the class appears, then defers to your real fix once you run it.
- **RECENT** — Your globally recent commands, deduped by text, substring-filtered against what you're typing.
- **IN THIS FOLDER** — Commands from this exact directory's history — what you actually do here.
- **AI / PREDICTED** — Learned success-successors ("after A you usually run B", recency-weighted), plus bundled-corpus completions of the partial, plus on-device semantic recall (INFERRED) that surfaces relevant commands by *meaning* the lexical tiers miss.

### How outcomes are learned

At each `BlockDone`, a tool-agnostic classifier turns the exit code + status + a bounded tail of output into a stable class token — matching the *English* of a failure, never a tool name. So an expired SSO token from any binary clusters into one `auth-expired` recovery bucket. Classes include `auth-expired`, `unauthorized`, `not-fast-forward`, `merge-conflict`, `not-found-binary`, `missing-dep`, `host-unreachable`, `daemon-unreachable`, `perm-denied`, `disk-full`, `rate-limited`, `timeout`, and `syntax`; unmatched-but-salient failures get a stable `fp:<hash>` fingerprint (learnable on the 2nd occurrence) and everything else an `x:<exit>` backstop, so the model is total.

The pattern store projects four learned namespaces onto one recency-weighted table (30-day half-life), persisted as JSONL:

```
S  ␟<cmdkey>           successors of a command that SUCCEEDED
E  ␟<class>            cross-command recovery for a failure class
EB ␟<class>␟<binary>   binary-scoped recovery (higher precision)
A  ␟<binary>␟<flag>    the concrete VALUES you pass a flag (profile/namespace/region)
```

The `A` namespace is what lets a suggestion carry the argument you actually use — a corpus template like `aws sso login --profile <profile>` is auto-filled to `aws sso login --profile dev`. Argument mining deliberately never learns values behind sensitive flags (`--token`, `--password`, `-p`, `-a`, `-u`, `-w`, …), never learns `@vault` references, and never learns `<placeholder>` spans, so no secret is ever persisted or re-suggested.

### History

Two history surfaces complement suggestions:

- `History` — folder-scoped command history (command, cwd, run count, last-run time), skipped for incognito tabs.
- `ShellHistory` — a read-only import of the login-shell history you already had *before* installing (`~/.zsh_history` / `~/.bash_history` / `$HISTFILE`). The engine reads it locally, strips the zsh `: <epoch>:<dur>;` prefix, de-dupes keeping the most recent, and returns it newest-first. Fail-soft: a missing/unreadable file yields an empty list, never an error.

> [!NOTE]
> The semantic (INFERRED) tier is a Cargo feature `semantic`, on by default. It pulls a native on-device embedder and downloads a \~130 MB model (BGE-small-en-v1.5) once from `huggingface.co` into `~/.openeng/terminal/state/models` — only the model comes down; no command history is sent. It is prewarmed at boot and time-bounded per keystroke, so it never blocks the instant lexical tiers and simply appears on a later keystroke once its index is ready. All learning is suppressed for incognito tabs.

## SSH, saved connections & the vaults

### Brokered SSH

`OpenSsh` opens an interactive session to a saved connection by running the system `ssh` binary locally — true local brokering through your own ssh config, keys, and agent. The engine builds a real argv (never a shell string, so there is no injection surface):

```
ssh [-p PORT] [-i KEYPATH] USER@HOST
    -p  only for a non-default port
    -i  only for the key method WITH a key path
```

It streams the same `PtyEvent` stream as `OpenInteractive` and reuses `WriteInteractive`/`ResizeInteractive`/`KillInteractive`. For the password ("prompt-on-connect") method, no secret is ever put on the command line — `ssh` prompts interactively inside the PTY.

### Saved connections

A `Connection` is your own remote target — `label`, `username`, `host`, `port`, an `auth_method` tag (`"key"` or `"pw"`), and an optional key-file `path`. Managed via `ListConnections`, `SaveConnection`, `DeleteConnection`. The record itself carries no secret: a passphrase is never stored; a key is referenced by path, never by bytes.

### SSH key store

Named SSH key references (`{name, path}`) are managed in Settings via `ListSshKeys`/`SaveSshKey`/`DeleteSshKey`. These are filesystem paths you typed (e.g. `~/.ssh/id_ed25519`), not key material, so they are stored as plain JSON (`ssh_keys.json`).

### The encrypted secret vault (`@name`)

The vault holds your own named values — e.g. a DB password — that you reference inside a command as `@name`. Managed via `ListVault`/`SaveVaultEntry`/`DeleteVaultEntry`. The value is **write-only across the boundary**: `ListVault` returns names only, there is no read-back RPC, and the value leaves the engine only as the expanded `@name` inside your own command, substituted *just before exec*:

```
psql -h db.internal -U admin -W @dbpass   # you type this
                                          # history + learning record the ORIGINAL, un-expanded line
                                          # only the running PTY ever sees the real value
```

- Values are encrypted at rest with ChaCha20-Poly1305; the 32-byte data key is a `0600` file (`secrets.key`) and the blob (`secrets.enc`) is `nonce(12) || ciphertext`.
- The engine never logs a value and never returns one over gRPC.
- A bare `@` or an unknown name is emitted literally — only a matching `[A-Za-z0-9_]+` name expands.

> [!WARNING] Warning
> A lost `secrets.key` is unrecoverable by design: the vault simply starts empty rather than wedging the engine. Treat the key file as the root of trust for your stored credentials.

## Cloud connectors (cloud terminal execution)

Beyond SSH, a saved connection can be `kind="connector"` — an HTTPS service (a cloud-shell / run-command / container-exec connector) that runs your command in *your own cloud* and streams back the output. The engine never holds cloud credentials; it holds only the connector's API key.

### Connectivity

The engine POSTs to the connector's OpenEng tools protocol. It prefers the streaming endpoint and transparently falls back to the buffered one for older connectors:

```
POST {base}/call-stream          # preferred (NDJSON); on 404-with-no-error-body → POST {base}/call
  x-api-key: <api_key>
  Authorization: Bearer <auth_token | api_key>   # empty token ⇒ the api key is reused as bearer
  x-write-key: <write_key>                       # sent ONLY when set (grants write access)
  { "name": "run_query", "arguments": { "query": "<command>", "database": "<target>" } }

  ← NDJSON frames:  {"t":"meta"} {"t":"chunk"} … {"t":"end"}   |   {"t":"error"}
```

Two RPCs drive it:

- **`ConnectorExecStream`** — Streams the connector output progressively as a run block (`BlockStarted → OutputDelta* → BlockDone`) — live cloud-shell output rather than one buffered blob. This is the path the UI uses.
- **`ConnectorExec`** — The unary twin: reduces the NDJSON frames back into one buffered result. Kept for back-compat; safe to retry only when the engine lacks the streaming RPC entirely.

An optional `target` is passed as the `run_query` "database" argument to select an environment/instance. NDJSON frames are rendered as a compact table + summary message in the console.

### Credentials, read-only, and write access

- On `SaveConnection`, the `api_key`, `auth_token`, and `write_key` are **write-only** — stored in the engine's encrypted secrets vault and never returned by `ListConnections` (which reports only `has_write_key` so the UI can show write state).
- A connection is **read-only by default**. Providing a plaintext write key sends it as `x-write-key`; the connector verifies it against its `WRITE_KEY` hash and grants that request write access. Command-restrict / read-only / force-exec gating is enforced connector-side — the write key is the switch the Terminal side controls.

> [!NOTE]
> When a connector rejects the credentials (HTTP 401/403), the engine stamps the sentinel `CONNECTOR_UNAUTHORIZED` onto the stream, surfaced by the UI as a re-auth prompt ("edit the connection and re-enter the api key"). Generate a write key at `connectors.openeng.app → Write key`.

## Configuration, storage & scopes

### Endpoint & process model

| Setting | Value |
| --- | --- |
| Bind address | `127.0.0.1:50052` (fixed — a zero-config product; the port is not configurable) |
| Browser endpoint | `https://engine.openeng.app:50052` (resolves to loopback; publicly-trusted cert) |
| Instances | One engine per machine, guarded by an `O_EXCL` PID lock (`appserver.lock`) |
| Home override | `OPENENG_HOME` relocates the whole `~/.openeng` root |
| Boot | `openeng-terminal serve --oauth=<one-time key>` (OAuth is the only supported path) |

### On-disk layout

```
~/.openeng/terminal/
  runtime/                 (0700)   lock, endpoint, handshake token, TLS material
    appserver.lock                  the one-per-machine PID lock
    appserver.endpoint              the URL a surface reads after bind
    handshake.token        (0600)   local handshake token
    ca-cert.pem / leaf-*.pem        self-signed loopback chain
    engine-fullchain.pem / -key.pem publicly-trusted cert (served when present)
    secrets.key            (0600)   the 32-byte vault data key
  state/                   (0700)   persisted, non-incognito data
    groups, history, patterns.jsonl the model + folder history
    secrets.enc            (0600)   the encrypted credential vault
    ssh_keys.json                   plaintext key-file PATH references
    connections.json                saved SSH / connector connections (no secrets)
    client-state/                   engine-side UI store, one file per key
    cache/                          the stale-while-revalidate cache
    shell-integration/     (0700)   per-engine OSC 633 rc files (0600)
```

Incognito groups/tabs write nothing here — they live in memory only.

### Engine-owned client state & caches

State the browser used to keep in per-browser `localStorage` is instead persisted on the local engine, so every browser on the machine sees identical data:

- `GetClientState` / `SetClientState` — a generic key→opaque-JSON store for UI caches (command-output blocks, the saved command/template library).
- `CacheGet` / `CacheSet` / `CacheDelete` — a stale-while-revalidate cache (each value engine-stamped with its refresh time), so the UI renders last-known state instantly, then refreshes.
- Auth sessions and the OAuth-key bootstrap cache deliberately stay per-browser and never touch these stores.

### High persistency (opt-in)

An engine-managed log of every finished command block to a JSON-lines file per terminal, keyed by tab id (`<dir>/<tab_id>.jsonl`). Configured via `GetPersistence`/`SetPersistence` (a toggle + a directory you pick, default under the OS temp dir, `/tmp/openeng-terminal` on Linux); browsed read-only via `ListPersistedTabs`/`GetPersistedTab` (the "View previous tabs" viewer has no command bar). Records are your own block data (command + output + exit/timing); the `@name` expansion is never recorded, and incognito tabs are excluded.

> [!WARNING] The default folder can be readable by other users
> The engine creates the folder and its files without setting permissions, so they follow your umask; with the usual default, other users on a shared machine can read every recorded command and its output. On a shared machine, pick a folder under your home directory, such as `~/.openeng/terminal/`.

### Security invariant & egress scope

The proto schema has **no field for an OpenEng secret or product prompt**; a schema-shape test scans it for secret-like field names, with the marked exceptions below exempted. The payload is your own terminal data: the command you type, the stdin/output bytes, your cwd/env/aliases. Your own credentials are the marked exceptions — a connector's `api_key`, `auth_token` and `write_key`, and a vault entry's value — and they reach only your engine over the sealed loopback. They cross the boundary only inbound (you setting your own value) and are never sent back: vault values are write-only, connector credentials are write-only, and the SSH key store holds paths, not bytes.

> [!TIP] Bottom line
> The browser is a rendering and input surface. Execution, credentials, history, and the learned model all live on your machine, reachable over the loopback channel. In the current Linux build that channel admits browsers only from the app's own origins (terminal.openeng.app and its two Firebase default domains) and one set of hosts; local programs that send no `Origin` can reach it too.
