# OceanCode — Developer Guide

> The Ocean coding agent: the `oceancode` engine (headless `run`, `serve`, `goal` and `workflow`; an ACP agent for Xcode) and Ocean pager, its terminal UI.
> 20 chapters: build and configure it, drive it from the terminal, bound a long run, extend it with capabilities, hooks and MCP servers, and the HTTP API (218 operations, read from the route table).
> Also: [One API docs](https://api.plungeai.com/docs) · [MCP guide](https://mcp.plungeai.com/docs) · [llms.txt](https://code.plungeai.com/llms.txt) · [This guide as markdown](https://code.plungeai.com/docs.md).
> Source: `apps/ocean-coding-agent-rust/docs/user-guide/` (build date 2026-10-17).

**Contents**

- [Getting started](#getting-started)
- [Configuration](#configuration)
- [Plan mode and todos](#plan-mode-and-todos)
- [Session ledger and export](#session-ledger-and-export)
- [Headless and server](#headless-and-server)
- [Bounding a long run](#bounding-a-long-run)
- [Capabilities and plugins](#capabilities-and-plugins)
- [Hooks](#hooks)
- [MCP servers](#mcp-servers)
- [Permissions and sandbox](#permissions-and-sandbox)
- [Jev decisions](#jev-decisions)
- [Platform mode (PlungeAI connectivity)](#platform-mode-plungeai-connectivity)
- [Testing and gates](#testing-and-gates)
- [Distribution and updates](#distribution-and-updates)
- [Operations](#operations)
- [Ocean pager — the terminal UI](#ocean-pager--the-terminal-ui)
- [Xcode and other ACP editors — `oceancode acp`](#xcode-and-other-acp-editors--oceancode-acp)
- [CLI reference](#cli-reference)
- [Ocean pager reference](#ocean-pager-reference)
- [HTTP API reference](#http-api-reference)

---

## Getting started

`oceancode` is one binary and one process. This page builds it, shows the two ways to start
it, and lists where it keeps its files.

### Build

```bash
cargo build --release -p ocean-cli
```

The binary is named `oceancode` and, because the workspace builds into a shared target
directory, it lands at the repository-root `target/release/oceancode` (that is
`../../target/release/oceancode` from this app directory). Put it on your `PATH` or call it by
that path.

### The three boot shapes

`oceancode` starts in one of three shapes, chosen by the subcommand.

| Shape | Command | What runs |
|---|---|---|
| Pager | `oceancode` | Ocean pager (`ocean-pager`) with the Ocean engine in the same process over ACP — the terminal UI. See [19 · Ocean pager](#ocean-pager--the-terminal-ui). |
| Editor | `oceancode acp` | The engine as an Agent Client Protocol agent on stdin/stdout — what Xcode and other ACP editors launch as a subprocess. See [20 · Xcode and other ACP editors](#xcode-and-other-acp-editors--oceancode-acp). |
| Headless | `oceancode serve`, or a non-interactive subcommand (`run`, `goal`, `workflow`, `ledger`, `export`, …) | The engine only, no UI: `serve` exposes the HTTP surface, the others run one job and exit. |

```bash
# 1. Pager + engine (the usual way)
oceancode

# 2. Headless server on port 4096
oceancode serve

# 3. One headless run
oceancode run "summarise the README"
```

The default mode accepts `--db`, `--directory`, `--listen` and `--verbose` (`-v`) and passes them
to the engine. `--tui pager` is accepted for compatibility and changes nothing;
`--tui ocean` fails with "the native TUI was removed; `oceancode` starts Ocean pager". Binding beyond
loopback needs `--listen` and a server password; see
[07 · Headless and server](#headless-and-server).

### First run

Authenticate once, then start a session.

```bash
oceancode auth        # interactive: API key or Claude Pro/Max subscription
# or, non-interactively:
oceancode login       # Claude Pro/Max subscription login
oceancode key <key>   # store an Anthropic API key
```

The credential is written to `auth.json` in the data directory (below) and reused on every
later run. Then launch `oceancode` and type at the prompt.

### Starting a session

A plain `oceancode` opens the pager on a fresh session. Earlier sessions are listed and resumed
from inside the pager — see its own user guide under
`ocean-pager/crates/ocean-pager-ui/docs/user-guide/`. Headless runs pick a session with
`oceancode run --continue` / `--session <id>`; see
[07 · Headless and server](#headless-and-server).

### Updating and removing

Keep the binary current, verify a download, or remove it:

```bash
oceancode update                 # update to the newest release for your channel (verified, atomic, rollback on failure)
oceancode verify-release oceancode-macos-aarch64.tar.gz   # check a download's signature before trusting it
oceancode uninstall              # remove the binary (add --purge to also remove config + data)
```

See [17 · Distribution and updates](#distribution-and-updates) for install one-liners, channels, and the
signature/checksum trust model.

### Where files live

Paths follow the XDG layout on every platform (macOS included — `~/.config`, not
`~/Library`). `<x>` below is the matching `$XDG_*_HOME` when set, otherwise the home-relative
default shown.

| What | Location |
|---|---|
| Global config | `$OC_CONFIG`, else `<XDG_CONFIG_HOME \| ~/.config>/oceancode/oceancode.json` |
| Credentials | `<XDG_DATA_HOME \| ~/.local/share>/oceancode/` — `auth.json` |
| Models catalog cache | `<XDG_CACHE_HOME \| ~/.cache>/oceancode/models.json` |
| Session database | `oceancode.db` in the working directory, unless `--db <path>` is given |
| Project config and assets | `<project>/.oceancode/` — `oceancode.jsonc`, `oceancode.json`, `skills/`, `plans/` |

### Next

- [02 · Configuration](#configuration) — the config keys and environment variables.
- [19 · Ocean pager](#ocean-pager--the-terminal-ui) — the terminal UI; its slash commands and keys are in
  `ocean-pager/crates/ocean-pager-ui/docs/user-guide/`.

---

## Configuration

`oceancode` reads a JSON config file and a set of environment variables. Nothing needs to be
configured to start — every key falls back to a built-in default.

### Config file locations and precedence

There are two config files, and both are optional.

| Scope | Path | Notes |
|---|---|---|
| Global | `$OC_CONFIG`, else `<config-dir>/oceancode/oceancode.json` | One file for every project you run. |
| Project | `<project>/.oceancode/oceancode.jsonc`, then `<project>/.oceancode/oceancode.json` | Per-repository, kept next to the code. |

`<config-dir>` is `$XDG_CONFIG_HOME` when set, otherwise `~/.config` — on every platform,
macOS included (the binary uses the XDG layout everywhere, not `~/Library`).

**Precedence, lowest to highest:** built-in default → global file → project file. Within the
project directory, `oceancode.json` is merged over `oceancode.jsonc` when both exist. Merging is
per section, last writer wins.

Comments and trailing commas are allowed in the `.jsonc` file.

### Top-level keys

Every key is optional; an empty or absent value takes the built-in default.
Unknown keys are ignored, so a file that still carries the native-TUI keys removed on 2026-10-05
(`tui`, `keybindings`, `disabled_providers`, `notifications`) loads unchanged.

| Key | Type | Default | Meaning |
|---|---|---|---|
| `$schema` | string | none | JSON-schema URL, for editor completion only. |
| `model` | string | provider default | Default model, as `provider/model`. |
| `default_agent` | string | `build` | Agent a new session starts in. |
| `agents` | object | none | Per-agent overrides, keyed by agent name. Each agent may set `model`, `prompt`, `temperature`, `disable`, and `tool_profile` (`default` / `codex` — `apply_patch` is the sole mutation tool / `hashline` — the content-anchored `hashline` tool replaces `edit`). |
| `providers` | object | none | Per-provider settings (endpoints, options). |
| `formatter` | bool or object | built-ins on | Code formatters: `false`/`true` toggles the built-ins; an object overrides them per formatter. |
| `instructions` | array of strings | none | Extra system-prompt sources — each a file path (`~`- or working-dir-relative) or an `http(s)` URL. |
| `mcp` | object | none | MCP servers, keyed by name. |
| `ocean` | object | none | Ocean Platform integration — see [15 · Platform mode](#platform-mode-plungeai-connectivity). |
| `permission` | array | `* * allow` | Base permission rules (`allow` / `ask` / `deny`); last matching rule wins. An empty config allows every call except the shell's dangerous floor, an `external_directory` ask and denied `.env` reads; add `bash * ask` to make every shell command off the read-only safe list ask (the posture Jev's permission point advises on, [13](#jev-decisions)). |
| `permission_mode` | string | ask | Default permission mode applied at boot: `acceptEdits`, `dontAsk`, `bypassPermissions`, or empty to ask on every side effect. An empty value never overrides an earlier layer. See [12 · Permissions and sandbox](#permissions-and-sandbox). |
| `shell` | object | built-in guard | Shell tool-guard overrides: `safe_list_extra` (extra read-only commands), `allow` / `deny` (`Tool(pattern)` rules layered on the classifier); plus `kind` (`auto` / `bash` / `sh` / `powershell` / `cmd`, default `auto`) — the shell the `bash` tool spawns, mainly for Windows (`auto` = `sh` on Unix, PowerShell on Windows). See [12](#the-shell-guard) and [17 · Distribution and updates](#distribution-and-updates). |
| `child_env` | object | strip secrets | How a spawned child's environment is composed: `inherit` (`all` / `core` / `none`), `include_only`, `set` (name→value), `strip_secrets`. See [12](#child-process-environment). |
| `sandbox` | object | off | OS isolation profile: `profile` (`off` / `workspace` / `readonly` / `strict`), `deny` (roots refused even inside the workspace), `network` (`off` / `allowlist`), `allow_hosts`. See [12](#the-sandbox). |
| `refs` | object | none | Project-reference aliases: `refs` maps `@name` → a path; a `@name/…` path in any file tool expands to it and its root is auto-allowed through the external-directory prompt. See [12](#reference-aliases). |
| `tool_output` | object | built-in thresholds | Overrides for tool-output truncation limits (built in: 2,000 lines / 50 KiB). A result over them reaches the model as a head preview with the full output saved to a spill file it can `read`; this applies to every tool that does not page itself, `bash` included. |
| `claude` | object | none | Claude Code (Pro/Max subscription) transport settings. |
| `ledger` | object | on, tideline = 40% of the model's context, block = 70% of the line | Session Ledger settings, under `ledger.tideline` (the aliases `ledger.shelve` and `ledger.window` still parse): `tideline.percent` (the tideline — the **line** — as a **share of the model's context**, `1..=100`, default 40 — while the window's real-token gauge is at or over it, the oldest closed block leaves the window); `tideline.block` (how much a block holds before it closes: a token count used verbatim (`70000`), or a **share of the line** as a string (`"35%"`, `1..=100`) that is re-derived whenever `/tideline` or the route changes the line — the rule "cut 35 % per cut" stays 35 % on every model; unset = 70% of the line; `0`, `"0%"`, `"101%"` and a string without `%` are refused at load); `tideline.tokens` (the fixed-token line used only when the model's context is unknown, default 300k; `0` = the model's own ceiling). The keys of the old band geometry — `low`, `reserve`, `profile` under the tideline block and `ledger.hard_tokens` — are removed: an old config still loads, they are ignored, and the load logs one warning naming them. `ledger.model` — the merge/lint model, default the session's (empty): every merge and lint call is a separate full-price request that never touches the session's cache prefix, so an empty value bills it at the session model's price — set a cheaper one (`provider/model`). Examples `{"ledger":{"tideline":{"percent":50}}}`, `{"ledger":{"tideline":{"percent":20,"block":"35%"}}}` (a 200 K line on a 1 M model, 70 K per cut), `{"ledger":{"tideline":{"tokens":500000,"block":20000}}}` or `{"ledger":{"model":"openai/gpt-5-mini"}}`. See [06 · Session ledger and export](#session-ledger-and-export). |
| `database` | object | no pruning | Session-database retention (`retention_days`, `vacuum`). |
| `compress` | string | `safe` | Prompt compression of tool results, `safe` (the default) or `off`. `safe` runs five lossless stages (ANSI strip → fold of identical lines → the session directory written `./` → a result identical to an earlier one becomes `[same output as <tool-use id>]` → a run of lines found in an earlier result becomes `[↑<n> lines same as <tool-use id>:L<a>-L<b>]`), on the copy of the request sent to the provider only — the stored session is never changed and the tideline cuts on the same turns to within the token estimate, and never earlier. Files and images are never touched. Overridden by `OC_COMPRESS`; an unknown value is read as the default and logs one warning. |
| `subagent` | object | none | Default native sub-agent model as `subagent.model` = `provider/model` (e.g. `{"subagent":{"model":"openai/gpt-5-mini"}}`). The model a session's sub-agent children run when the session sets no `/subagent` override and the fleet router pins none (`env.CLAUDE_CODE_SUBAGENT_MODEL`, applied while the subscription slot is live); empty (unset) leaves children on the spawning session's own model. A value without a `/` is rejected. |
| `workflows.enabled` | bool | `true` | Whether the host-run workflow conductor and goal mode are available at all. `false` turns the conductor off for the session. |
| `workflows.max_concurrent_agents` | number | `8` (`1..=1024`) | Concurrency ceiling: how many agents a single workflow run may execute at once. A value outside `1..=1024` is rejected at load. |
| `workflows.agent_budget` | number | `128` (`1..=1024`) | Total number of agents a single run may spawn before it stops. A value outside `1..=1024` is rejected at load. |
| `workflows.max_run_minutes` | number | `120` | Wall-clock cap on a single run, in minutes. |
| `workflows.agent_timeout_minutes` | number | `20` (`0` = off, else `1..=1440`) | Per-child await bound: how long the host waits on one sub-agent (a single `agent()` call) before it cancels that child alone and returns it to the script as `success:false` with `stop_reason: "timeout"` — the run and its other agents keep going. `0` disables the bound (only the run's `max_run_minutes` then applies). A non-zero value outside `1..=1440` is rejected at load. |
| `workflows.orchestrate` | string | `off` | `off` \| `always`. `always` wraps every prompt in the `goal` workflow (goal mode): the prompt launches a background goal run and the turn returns at once, with the run's outcome arriving later as a workflow result in the session; `off` runs each prompt as a plain turn. This is a config key only — Claude Code's `ultracode` setting does NOT switch it on. |
| `skills.mode` | string | `all` | Skill discovery mode: `all` (every discovered skill is a candidate) or `allowlist` (only skills whose id is in `skills.enabled` are candidates; every other skill is disabled and costs zero prompt tokens). This is the **project tier** — the per-skill *user* toggle lives in `~/.oceancode/skill-state.json` (`oceancode skill enable/disable`). See [09 · Capabilities](#enabling-and-disabling-skills). |
| `skills.disabled` | array of strings | none | Qualified skill ids the **project** turns off. A project can disable a skill but can never re-enable one the user disabled — the user tier is resolved first. |
| `skills.enabled` | array of strings | none | The allowlist consulted only when `skills.mode = "allowlist"`: the skill ids that stay enabled — each a stable `<tier>.<eco>:<name>` id (see [09 · Capabilities](#enabling-and-disabling-skills)) or a bare name (matching every copy of that name). |
| `skills.roster` | string | `full` | How the system prompt lists skills: `full` (an `<available_skills>` element with name, description and location per skill) or `names` (one line of names, no descriptions — the deterministic control arm the Jev bench compares against). A Jev-narrowed roster (`jev.relevance = "act"`) wins over `names` and renders as `full`. |
| `jev` | object | off | The TypeSafe Jev decision layer: `enabled` (needed in BOTH the global and the project config), `endpoint`, `model` (versioned id), budgets, one mode per decision point (`permissions`, `output_view`, `supervisor`, `triage`, `tools`, `relevance`, `visibility` = `off`/`shadow`/`act`), `thresholds`. A project config may set only `enabled` and the modes. See [13 · Jev decisions](#jev-decisions). |
| `update` | object | auto-check on, `stable` | Self-updater settings: `auto_check` (bool, default `true`) checks for a newer release on boot; `channel` (`stable` / `beta`, default `stable`) selects release visibility; `endpoint` (string, unset → GitHub Releases) overrides the release-metadata source. Overridable per run by `OC_NO_UPDATE_CHECK` / `OC_UPDATE_CHANNEL`. See [17 · Distribution and updates](#distribution-and-updates). |
| `queue` | object | `default_delivery: queue` | Prompt-queue defaults: `default_delivery` (`queue` — after the live turn, or `steer` — into the live turn) is how a prompt with no explicit delivery is admitted. |
| `jobs` | object | `max_concurrent: 4`, `spill_max_bytes: 5242880` | Background-job limits for the `bash background:true` / `monitor` / `loop` / `scheduler` tools: `max_concurrent` running jobs and `spill_max_bytes` per-job output-spill cap. There is no job timeout: a job runs until it ends or `job_kill`, unless the run has a wall clock (`OC_MAX_MINUTES`), which reaps it. |
| `checkpoints` | object | `enabled: true` | File checkpoints: `enabled` toggles the per-project snapshot/restore behind `/undo`·`/redo` (the user's own `.git` is never touched). |
| `context` | object | `durable_rules: true` | Context-epoch options: `durable_rules` injects each entered directory's rule file once as a durable message so it survives context compaction. |
| `model_fallback` | array of strings | none | Ordered `provider/model` routes tried, in order, after the primary model fails. Paired with `fallback`. |
| `fallback` | object | `cooldown_seconds: 60` | Provider-fallback options: `cooldown_seconds` is how long a `model_fallback` route is skipped after it fails. |

### Environment variables

Set these in the shell before launching. A slash command or an `oceancode run` flag, where one
exists, overrides the environment for that session (see
[08 · Bounding a long run](#bounding-a-long-run)).

| Variable | Meaning | Default |
|---|---|---|
| `OC_CONFIG` | Absolute path to the global config file, overriding its default location. | unset → `<config-dir>/oceancode/oceancode.json` |
| `OC_CONFIG_DIR` | Directory that holds the project `oceancode.jsonc` / `oceancode.json`. When set (non-blank), the project config is read from `$OC_CONFIG_DIR` instead of `<cwd>/.oceancode/`, so config can live outside the repo — a read-only checkout, or a benchmark board that grades the whole workspace tree (B6). Only the project tier moves; the user and managed tiers are unaffected. | unset → `<cwd>/.oceancode/` |
| `OC_MANAGED_CONFIG` | Absolute path to the managed (admin/enterprise) config file, overriding its OS default. It is the highest-precedence layer, so it overrides the project and global files. See [12 · Permissions and sandbox](#managed-enterprise-config). | unset → macOS `/Library/Application Support/oceancode/managed_config.json`, Linux `/etc/oceancode/managed_config.json` |
| `OC_REQUIREMENTS_PUBKEY` | Trusted ed25519 public key (64 hex chars) that a managed `requirements.json` signature is verified against; an unsigned or tampered file is refused, never applied. | unset → no key; managed requirements are not enforced |
| `OC_SERVER_USERNAME` | Basic-auth username the HTTP server accepts. | `oceancode` |
| `OC_SERVER_PASSWORD` | Basic-auth password. Empty disables auth; a non-loopback bind refuses to start without it. | unset (auth off) |
| `OC_MODELS_URL` | Override URL for the models catalog. | the built-in models.dev URL |
| `OC_MODELS_PATH` | Path to the local models-catalog cache file. | a file under the cache dir |
| `OC_DISABLE_MODELS_FETCH` | Any non-empty value skips fetching the remote models catalog (offline). | unset (fetch on) |
| `OC_NO_UPDATE_CHECK` | Any non-empty value suppresses the boot-time check for a newer release. The check is also skipped on first run and when `update.auto_check` is `false`; the first launch is telemetry-free regardless. See [17 · Distribution and updates](#distribution-and-updates). | unset (check on) |
| `OC_UPDATE_CHANNEL` | Force the release channel for `oceancode update`: `stable` or `beta`. Overrides the `update.channel` config key for that run. | unset → `update.channel` (default `stable`) |
| `OC_INSTALL_DIR` | Install / uninstall destination directory for the installers and `oceancode uninstall`. | unset → `$HOME/.local/bin` (Windows `%USERPROFILE%\.oceancode\bin`) |
| `OC_MINISIGN_SECRET_KEY` | Release lane only — path to the minisign secret-key file `scripts/sign/sign-artifacts.sh` signs artifacts with. Read by the release scripts, never by the running binary; never committed. | unset |
| `OC_WINDOWS_CERT_THUMBPRINT` | Release lane only — Authenticode code-signing certificate thumbprint `scripts/sign/authenticode-windows.ps1` uses. Read by the release scripts, never by the running binary. | unset |
| `OC_TUI` | Compatibility only. `pager` is accepted and changes nothing (`grok` is refused as unknown) — `oceancode` always starts Ocean pager; `ocean` fails with "the native TUI was removed; `oceancode` starts Ocean pager". `--tui` takes the same values. | unset → Ocean pager |
| `OC_PAGER_BIN` | Absolute path to the Ocean pager binary (`ocean-pager`) that `oceancode` execs. | unset → beside `oceancode`, then `PATH` |
| `OC_PAGER_HOME` | Set by `oceancode` for the pager it execs: the pager's state directory. Read by `ocean-pager`, not the engine; every pager variable is in [19 · Ocean pager](#environment). | `<data>/oceancode/pager` |
| `OC_RG_BIN` | Absolute path to a ripgrep (`rg`) binary. The `bash` tool rewrites a plain recursive `grep -r …` to `rg` and puts a directory holding that binary as `rg` first on the child `PATH` (a symlink/copy shim under `<data dir>/bin/` when the file is named otherwise). The pager passes its embedded ripgrep automatically; set this only to override it or on a machine without one (Windows). | unset → the pager's embedded `rg`, then `rg` beside `oceancode`, then `rg` on `PATH` |
| `OC_MAX_MINUTES` | Wall-clock bound for a run, in minutes. Background jobs the run starts (`bash background:true`, `monitor`, `loop`, `scheduler`) are reaped with it; unset, they run until they finish or `job_kill`. | unset (no clock bound) |
| `OC_MAX_USD` | Spend bound for a run, in US dollars. | unset (no budget bound) |
| `OC_MAX_STEPS` | Per-turn step cap at the root, `1`–`1000`. A turn boundary the run auto-continues past, not a stop. | `300` |
| `OC_MAX_SUBAGENT_STEPS` | Per-turn step cap for a sub-agent. Sub-agents stop at it. | `300` |
| `OC_PLAN` | `off` disables the planning discipline for the run: no todo directive in the system prompt on either route, no plan-missing reminder, and a settled turn with open todos is not worked off. The PLAN panel, `/plan` and any todo block the model writes keep working. See [05 · Plan mode and todos](#plan-mode-and-todos). | unset (on) |
| `OC_COMPRESS` | Prompt compression profile for the run, `safe` or `off`; overrides the `compress` config key. An unknown value is read as the default and logs one warning. | unset → the `compress` key (default `safe`) |
| `OC_READ_STEER` | `off` lets pure file reads through the `bash` tool again. By default `cat <paths>`, `head [-n N]`, `tail [-n N]` and `sed -n '<range>p'` on files that exist under the command's working directory are refused before any spawn, with the exact `read` call (offset/limit derived from the range) in the error — `read` adds line numbers, paging and truncation guards, and its freshness tracking is what a later `edit` checks. Pipes, redirects, chains, globs, missing paths, `tail -f` and `sed -i` always run. POSIX shells only. | unset (steer on) |
| `OC_MAX_OUTPUT_TOKENS` | Overrides the model's per-request output-token cap, clamped to the model's own limit. | the model's published maximum |
| `OC_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | `0` or `false` disables background sub-agents (the `task` tool's `background: true`); on by default since 2026-10-13. Foreground and background sub-agent runs alike now emit spawned/finished lifecycle frames — foreground runs also stream progress frames — that the Ocean pager renders as a live sub-agent roster. | unset (on) |
| `OC_WEBSEARCH_PROVIDER` | Force the web-search provider: `exa` or `parallel`. | chosen per session |
| `OC_MCP_META_TOOLS` | `1` swaps the flat MCP tool catalog for the `search_tool`/`use_tool` meta-dispatch once the connected tool count passes 64. Above 128 tools the swap always happens (providers reject a longer `tools` array). See [11 · MCP servers](#mcp-servers). | unset (off below 128; forced on at 129+) |
| `OC_MCP_WAIT_SECONDS` | Whole seconds a prompt's first model request waits for the MCP servers configured at boot to finish connecting, so their tools are in that request instead of arriving in the next one (a tool list that grows between two steps of a prompt re-writes the provider's cache). `0` never waits; a server still connecting at the bound is not waited for. Sub-agents never wait. See [11 · MCP servers](#mcp-servers). | `5` |
| `OC_LOG` | Engine log level for the redacting tracing subscriber: `trace`, `debug`, `info`, `warn`, or `error`. Overrides the `obs.log_level` config key; an unrecognized value logs a one-line notice and falls back to `info`. Secret-shaped values are masked before any log write. See [18 · Operations](#log-levels-oc_log). | unset → `obs.log_level` (default `info`) |
| `OC_TERMINAL` | Set to `1` by the app in every shell and tool it spawns, so tools can detect they run under oceancode. Exported, not read. | set by the app |
| `OC_HOOK_EVENT` | Set by the app on every hook command handler to the firing event's name (`PreToolUse`, `Stop`, …). Exported, not read. See [10 · Hooks](#hooks). | set per hook invocation |
| `OC_PROJECT_DIR` | Set by the app on every hook command handler to the project directory (also exported as `CLAUDE_PROJECT_DIR`, and used as the handler's working directory). Exported, not read. See [10 · Hooks](#hooks). | set per hook invocation |
| `OC_MODEL_FORMAT` | Legacy tool-result format switch; no longer read — every result renders in the native markdown format. | inert |
| `OC_TUNNEL_TOKEN` | Platform build. The OceanTunnel session token, read at the highest priority — above `OCEAN_TUNNEL_TOKEN`, then `ocean.tunnel_token_file`, then the inline `ocean.tunnel_token`. It supplies a token; it does not turn platform mode on. See [15 · Platform mode](#platform-mode-plungeai-connectivity). | unset |
| `OCEAN_TUNNEL_TOKEN` | Deprecated: still read as a legacy fallback for `OC_TUNNEL_TOKEN` (logs one deprecation warning per process); set `OC_TUNNEL_TOKEN` instead. See [15 · Platform mode](#platform-mode-plungeai-connectivity). | unset |
| `OC_SSO_TOKEN` | Platform build. A per-request gateway SSO session token. When set, the route layer sends it as the `X-Ocean-SSO-Token` request header (no `Bearer` prefix) on the configured **https** gateway host only, leaving the wallet `Authorization: Bearer` (your `ozk_` key) intact; read at runtime, never persisted. NOTE: the inference gateway does not yet read `X-Ocean-SSO-Token`, so it is ignored server-side until that lands. To store one, use `oceancode login --sso-token`. See [15 · Platform mode](#platform-mode-plungeai-connectivity). | unset |
| `OC_JEV` | `off`, `shadow` or `act`: overrides every Jev decision point's mode (`off` turns the layer off whatever the config says). It never turns the layer on by itself — `jev.enabled` must still be set in both the global and the project config. | unset → the config |
| `OC_JEV_ENDPOINT` | Overrides `jev.endpoint` (the Jev `POST` URL). The only way to use plain `http`, and only to a loopback host (`127.0.0.1`, `localhost`, `::1`). | unset → `jev.endpoint`, else the build default |
| `OC_TYPESAFE_API_KEY` | The TypeSafe API key, sent only to `api.typesafe.ai` (never to another host); never in config; stripped from child processes like every `*KEY*` variable. The direct API without it keeps the layer off. | unset |

#### Test and bench knobs

These steer test, benchmark, and profiling runs; they are not part of normal operation.

| Variable | Meaning | Default |
|---|---|---|
| `OC_DETERMINISTIC` | `1` pins ids, clocks, and other non-determinism for reproducible runs and replay. | unset |
| `OC_METRICS_ADDR` | `host:port` to serve runtime gauges (uptime, worker/task counts) for profiling. | unset (nothing listens) |
| `OC_TEST_HOME` | Overrides the home directory used for path resolution, for tests. | the real home directory |
| `OC_CACHE_HINTS_MUTATE` | Toggles prompt-cache-hint mutation, for cache experiments. | unset |
| `OC_CACHE_KEEPALIVE_MINUTES` | Minutes between prompt-cache keep-alive touches. While a step waits (a sub-agent, a long `bash`) or the session idles, the engine re-sends the last request with `max_tokens: 1` at this interval so the provider's cached prefix is read again and its one-hour clock restarts — a cache read instead of the full re-write a cold return pays (about 1:80 on Fable 5.1: 0.025× read against a 2× one-hour write, ~$0.06 against ~$5 on a 250k prefix). `0` or `off` disables it. Each touch is a `session.cache.touched` event and its cost joins the session's spend. | `50` |
| `OC_CACHE_KEEPALIVE_MAX` | Touches per request before the keep-alive gives up (≈ 40 h at the default interval; a model's break-even against one 2× re-write is 2× ÷ its cache-read price: 80 touches on Fable 5.1, 20 on a 0.1× model). | `48` |
| `OC_CONFORMANCE_TARGET_URL` | Target server URL for the conformance harness. | unset |
| `OC_TEST_TIMEOUT_SCALE` | Multiplies harness timeouts (`ocean-testkit`, dev-only); floored at `1.0`, so it can only lengthen, never shorten, a timeout. | unset (`1.0`) |
| `OC_LIVE_CONFIRM` | `1` arms the money-gated live benchmark drivers (`scripts/bench/task11-stratified.sh`, `swebench-parity.sh`, `marathon-month-gate.sh`, `run-official-board.sh`) to spend against a real model; unset, each refuses to spend and only `--self-test`s. Never set by `check.sh`. | unset (never spends) |
| `OC_SQLITE_CACHE_KB` | Per-connection SQLite page-cache size in KiB (`PRAGMA cache_size = -<kb>`), applied to every writer and read-pool connection. A soak/measurement knob for isolating engine memory from the box's page cache; leave unset in normal use. | unset → SQLite default (≈64 MB) |
| `OC_READ_POOL_SIZE` | Number of connections in the session store's read pool. A soak/measurement knob to bound per-connection memory when profiling; leave unset in normal use. | unset → `available_parallelism()` |
| `OC_JEV_FIXTURE_DIR` | Directory the Jev policy-table tests read their recorded fixtures from, instead of each crate's `tests/fixtures/jev` (read by `ocean_jev::testkit::load_fixture`; set by `scripts/gates/check-jev.mjs --self-test` to a planted copy). Test-only; the binary never reads it. | unset (the crate's own fixtures) |
| `OC_LLM_CACHE_DIAG` | `1` adds `"diagnostics": {"previous_message_id": …}` (the newest Anthropic response this process received) to every Anthropic Messages request and logs each response's cache read / creation / input tokens and `cache_miss_reason` at info as `OC_LLM_CACHE_DIAG` (read by `ocean_llm::route::client`). Diagnostic for prompt-cache audits; `scripts/probes/live-steer-cache-probe.sh` uses it. | *(unset)* |
| `OC_LLM_DUMP` | Directory to write every provider-native request body into as `<seq>.json` (a process-wide counter), so two consecutive steps can be diffed byte for byte in a prompt-cache audit (read by `ocean_llm::route::client`). Diagnostic; bodies contain the whole context — use a private directory and delete it afterwards. | unset (off) |
| `OC_TOOL_REPLAY` | Path of a recording (`{"<tool call id>": {"ok": bool, "text": string}}`); the tool registry then answers every call from it by the model's call id instead of running the tool (an unrecorded id is an error result), so a recorded `bash` or `edit` never runs again. Compiled in only with `ocean-agent`'s `test-util` feature (`cargo build --release -p ocean-cli --features test-util`, which `scripts/probes/real-replay-probe.sh` does into `target/replay-bin`) — the shipping build never reads it. An unreadable or malformed recording stops the process. | unset |

### Claude Code settings mirror

On the subscription route (`claude-code/…` models, run through the `claude` CLI) OceanCode follows
Claude Code's own settings, so both tools start the same way. It reads three files, lowest
precedence first, and merges them per key (`modelSettings` per model):

| Tier | Path |
|---|---|
| user | `~/.claude/settings.json` |
| project | `<project>/.claude/settings.json` |
| local | `<project>/.claude/settings.local.json` |

| Key | What OceanCode does with it |
|---|---|
| `model` | With no `model` in `oceancode.json` and `claude` on this machine, the catalog default and every new session are pinned to `claude-code/<model>` — the id verbatim, `[1m]` suffix included (`--model claude-fable-5-1[1m]`). An explicit `model` in `oceancode.json` wins. |
| `effortLevel`, `modelSettings.<model>.effortLevel` | The effective effort (`modelSettings` over `effortLevel`, matched with and without the `[1m]` suffix) becomes the pinned variant and reaches the CLI as `--effort <level>` — `low`, `medium`, `high`, `xhigh` or `max`; any other value is shown but not passed. `ultracode` plays no part: since Claude Code 2.1.284 it is its own toggle at any effort level, so the written effort (the fleet router's `max`) is what the row, the pinned variant and the wire carry (2026-09-29) |
| `ultracode` | Drives the status bar's `⚡ ultracode on` mark (and the persisted preference). `/ultracode` still toggles it by hand until the next settings change. Claude Code honours the same key (`"ultracode": true` makes every Claude Code session ultracode). |
| `env.CLAUDE_CODE_SUBAGENT_MODEL` | The sub-agent model the fleet router pins. Applied on the native route while the subscription slot is live (2026-09-29): shown on the identity row as `sub-agents <name>` and the model a session's children run, as `anthropic/<id>` at that id's `modelSettings` effort — unless `/subagent` overrides it per session (the override holds until the router pushes a new value, then it is cleared). A router push re-seeds running sessions without a restart. |
| `env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE` | `"1"` marks the router's sub-agent model as forced. Reported (`GET /claude/settings`) and read alongside the model; not enforced — an explicit `model` on a task call still outranks the router's default. |
| `alwaysThinkingEnabled` | Reported (`GET /claude/settings`); not yet acted on. |
| `hooks` | See [10 · Hooks](#where-hooks-are-read-from--and-when-they-run). |

**Live.** The engine watches both `.claude` directories. Saving any of the three files re-reads the
mirror and the hooks within about a quarter second: sessions still on the previously mirrored
model move to the new one (a model you picked by hand in `/models` is kept), and the UI shows
`Claude Code settings reloaded — <model> · effort <level> · ultracode on|off` when a mirrored value
changed (the fleet router rewrites the file periodically; an unchanged rewrite is silent).

Nothing is mirrored when `claude` is not installed (the settings are still read and reported) or
when the files name no `model`. Managed (enterprise) settings are not read. The watcher follows the
`.claude` directories that exist when the engine starts — a project `.claude/` created afterwards is
read at the next start (or `/hooks` → `r`).

### See also

- [01 · Getting started](#getting-started) — where these files live on disk.
- [07 · Headless and server](#headless-and-server) — `OC_SERVER_USERNAME` / `OC_SERVER_PASSWORD`.

---

## Plan mode and todos

Two separate things share the word "plan": the **step list** the model keeps as it works, and
**plan mode**, a read-only agent for thinking before touching anything.

For the full design and the code paths, see PLAN-MODE.md (`apps/ocean-coding-agent-rust/docs/PLAN-MODE.md`); this page is the
operator's summary.

### The step list (todos)

For work with three or more distinct steps the model keeps a todo list — one item per step —
and Ocean pager's todos pane shows it live. A single edit, one command or a plain question
gets no list: the same rule Claude Code, opencode, Kilo Code and Grok Build use. Each row ticks
through four states:

| Glyph | Meaning |
|---|---|
| `[ ]` | pending |
| `[»]` | in progress |
| `[✓]` | completed |
| `[✕]` | cancelled |

The model writes the list as a fenced
` ```todo ` block in its reply (or through its todo tool); the newest complete block replaces
the whole list, so the pane always reflects the latest one. In the pager, `ctrl+t` toggles the
todos pane (see [19 · Ocean pager](#ocean-pager--the-terminal-ui)).

If the model skips the list and then does real multi-step work anyway (twelve or more tool
calls with no plan), the next step carries a reminder to write it; a plain answer is never
reminded. A settled turn that still has open items is worked off (see
[08 · Bounding a long run](#bounding-a-long-run)). `OC_PLAN=off` turns the directive,
the reminder and the work-off off for a run; the todos pane and `/plan` keep working.

#### The always-plan floor

If the model has not planned yet, `oceancode` shows one synthesized row — your prompt text — so
the list is never empty. The moment the model writes a real plan, that floor steps aside and
does not come back for the session.

#### Editing the plan yourself — `/plan`

You can edit the list while a step runs; the model merges your changes with its own on the next
step.

| Command | Effect |
|---|---|
| `/plan add <text>` | Add a pending row. |
| `/plan done <n>` | Mark row `n` completed. |
| `/plan cancel <n>` | Mark row `n` cancelled. |
| `/plan edit <n> <text>` | Rewrite row `n`. |
| `/plan remove <n>` | Delete row `n`. |

A row you add is open work: the run keeps going until it is done or a bound trips (see
[08 · Bounding a long run](#bounding-a-long-run)). If you add a to-do while the agent has
already finished, it counts as progress — the run picks the work back up for one more turn
instead of stopping.

### Plan mode

Plan mode is a distinct **agent**, not a key. In it the model may read and search but may not
edit or run side-effecting tools — the one exception is writing plan files under
`.oceancode/plans/`.

- **Enter it** by selecting the `plan` agent — in Ocean pager, a bare `/plan` or
  `/plan <description>` (the row-edit verbs above are the only `/plan` forms that do not switch
  mode). Every turn then carries a reminder that editing is off.
- **Leave it** when the model calls `plan_exit`: it asks "switch to the build agent?", and on
  your confirmation the session flips to the `build` agent, which may edit and run. The
  approval surface lets you **approve** (`y`), **reject** (`n`), or attach inline comments that
  are appended to the plan as user-authored rows before you decide.

The step list keeps working throughout — plan mode changes *what the model may do*, not how
progress is tracked.

### See also

- [06 · Session ledger and export](#session-ledger-and-export) — where the plan and turns are recorded.

---

## Session ledger and export

A long session would grow to a huge, ever-slower context. `oceancode` avoids that with the
**session ledger**: the model sees a small, fixed window of recent turns, and everything older
is kept verbatim in Markdown files beside the session — never summarized away.

The full concept is in SESSION-LEDGER-CONCEPT.md (`apps/ocean-coding-agent-rust/docs/SESSION-LEDGER-CONCEPT.md`); this page is
the operator's view.

### What the ledger is

- **A window of the raw conversation.** The model always sees the newest part of the real
  conversation, up to a bounded size — by default **40% of the model's context** (400k on a 1M
  model, 80k on a 200k model). The window is measured in the provider's real tokens after every
  step, and the oldest content leaves it one **block** at a time — inside a long turn
  too (see **The tideline** below). `tokens: 0` instead tracks the model's own ceiling —
  its context window minus the output allowance; 120k only when the route reports no limit. A
  *turn* is your message plus the model's full answer, tool outputs included.
- **Per-turn transcripts on disk.** Every step is written verbatim to its turn's transcript file
  as it ends, and the index files are updated when the turn completes. As the window grows the
  oldest blocks leave it — they are already on disk, so nothing is lost and no model call is made.
- **`SESSION.md`, one living page.** Goal, decisions, environment facts, errors and their
  fixes, a code map, open questions, and the next step — each line stamped with the turn it came
  from. Facts the database already holds (files touched, commands and exit codes, tasks, the
  log) are written by the engine. The page is *edited*, never rewritten, and by one writer;
  reversed decisions keep the old text struck through rather than deleted.

The ledger ships **enabled** (`ledger.enabled: true`), so every session is laddered from its first turn with no setup; its per-turn transcripts are retained for 30 days by default (`ledger.retention_days`). See [02 · Configuration](#configuration) to change either. **OceanCode never summarizes a session** — the legacy compaction (summarization) path runs only when `ledger.enabled` is `false`.

Find a session's ledger directory with:

```bash
oceancode ledger path <session-id>
```

### The tideline — `/tideline`

You set **one number**: the share of the model's context where the tideline sits
(`ledger.tideline.percent`, default `40`) — the **line**. After every step the ledger reads the
window's size in the provider's real tokens (the **gauge**: input + cache read + cache write of the
step that just ended, plus what that step added — its output and tool results; with `compress` on, plus the
compressor's saved estimate, removed bytes ÷ 4, so the gauge still measures the uncompressed window). Steps are grouped
into **blocks**: a block closes once the gauge has grown by `ledger.tideline.block` since it opened — a
token count, or a share of the line written `"35%"` (unset = 70% of the line, so 70 000 at a
100 000-token line). While the gauge is at or over the line, the oldest closed block leaves the
window, so the window drops to about line − block and grows again. The block still being filled is
never cut, so the window overshoots the line by at most one block. A single turn larger than the
line is cut block by block while it runs; your message in the oldest block leaves with it.

After a cut the window opens with one line that points at the ledger:
`[Earlier content of this session is past the tideline: every row up to seq <n> left this window. The
verbatim record is <ledger>/transcript/ (one file per turn) and SESSION.md carries the merged state. …]`.
A turn whose every row is past the tideline is merged into `SESSION.md`; a turn cut only in part is
not. If the provider still refuses a request as too long, every closed block is cut before the one
retry.

Between cuts every step only appends, so the provider's prompt cache hits; each cut re-writes the
retained window once. The block size sets how often: a 50k block cuts about 20 times per 1M new
tokens past the line, a 20k block 50 times. When the model's context is unknown the fixed `tokens`
(default `300000`) is the line. A line above what the model can hold (its context minus the output
allowance, at most 32k) is clamped to that, with one note in the log.

The status bar's context row measures against the model's own context (`…/1.0M` on
`claude-fable-5-1[1m]`); the `┃` on its gauge sits at the tideline — `tideline @40% (400k): 238k to go`, or `tideline
@300k: 138k to go` under a fixed limit — and it reads `tideline off` when the ledger is disabled.

Config examples (`ledger.shelve` and `ledger.window` are still read as aliases):

```json
{ "ledger": { "tideline": { "percent": 50 } } }
{ "ledger": { "tideline": { "tokens": 500000, "block": 20000 } } }
```

`tokens: 0` means the model's own ceiling. The older keys — the floor (`low`), `reserve`, the
`profile` presets (`wide` · `narrow` · `strict` · `auto`) and `ledger.hard_tokens` — were removed
with the band geometry: an old config still loads, they are ignored, and the load logs one warning
naming them.

Change the tideline for a session at runtime with `/tideline`:

| Command | Effect |
|---|---|
| `/tideline` | Show the current tideline state. |
| `/tideline now` | Move the tide now (a cut). |
| `/tideline 50%` | Set the limit as a percent of the model's context. |
| `/tideline 500k` | Set a fixed limit. |
| `/tideline default` | Restore the configured limit. |

Minimum fixed limit 20k; `k` and `m` suffixes are accepted. The floor and the presets were removed;
a second number or a preset word is refused with the usage line. A change applies from your next
message on, which the confirmation says. Headless, the same set is
`--tideline "<percent>% | <limit> | default"`. A sub-agent (child) session inherits its parent's
`/tideline` override — the percentage, or the limit.

The meter's denominator is the model's context window under `auto` and the effective window
otherwise.

### The session meter

Ocean pager has no cockpit panel; its status bar and `/context` show the session meter:

- the **context meter** — what the current window costs against the session's window size;
- **token counts** — input and output for the session;
- **spend** — `$spent / $cap` on a metered route. On the Claude Pro/Max subscription route the
  bill is a list-price *estimate*, shown as `≈$spent list · subscription`; a `limits` row
  appears when a step or time bound is set.

On the Claude Code route the meter's **context** number is Claude Code's own real context — its
system prompt, `CLAUDE.md` files and MCP schemas, ~100k+ before any turn. When it reaches the limit
OceanCode drops the `--resume` id and cold-starts Claude Code with the ledger page plus the retained
turns' text (tool outputs are not re-sent — the page's links carry them), so the number restarts near
that baseline and climbs again.

### Recovering a step — `/undo` and `/redo`

`/undo` reverts the previous message and `/redo` re-applies a reverted one, stepping a bookmark
through the session so you can back out a bad turn without losing the history.

### Exporting a session

Write a whole session out as one file:

```bash
oceancode export <session-id> --md   [--out <path>|-]   # one Markdown document
oceancode export <session-id> --atif [--out <path>|-]   # one ATIF-v1.8 trajectory (JSON)
```

Pass exactly one of `--md` or `--atif`. `--out -` (the default) writes to stdout; `--out
<path>` writes a file. The Markdown export carries frontmatter, every turn, and a `## Summary`
table, and is built from the ledger's own transcript files when they exist.

### Rebuilding the ledger

If a ledger directory is lost or a session predates the ledger, rebuild it from the database:

```bash
oceancode ledger rebuild <session-id>            # mechanical: transcripts, indexes, page
oceancode ledger rebuild <session-id> --merge    # also fold the merge backlog (model calls; asks y/N)
oceancode ledger rebuild <session-id> --merge --yes   # skip the confirmation
```

The plain rebuild makes no model calls; `--merge` does, so it is never implied.

### See also

- [02 · Configuration](#configuration) — the `ledger` and `database` config sections.
- [08 · Bounding a long run](#bounding-a-long-run) — spend and clock bounds.

---

## Headless and server

`oceancode` runs without a UI two ways: a one-shot `run` for scripts and CI, and a long-lived
`serve` that exposes the engine's HTTP surface to clients. The terminal UI is Ocean pager, which
embeds the engine in its own process (see [19 · Ocean pager](#ocean-pager--the-terminal-ui)); `serve` is the
headless surface.

### One-shot — `oceancode run`

Run a single non-interactive turn and exit.

```bash
oceancode run "explain what this repo does"
oceancode run --format json "fix the failing test"
oceancode run --continue "and now add a test for it"
```

Flags go before the prompt (a flag after it is sent to the model as text, with a warning). When
stdin is not a terminal, `run` reads it to EOF and appends it to the prompt — the piped body is
the instruction — so a script that passes the prompt as an argument must close stdin
(`oceancode run "hi" </dev/null`) or the run waits forever (observed 2026-10-05: four minutes of
nothing with stdin left open).

Useful flags:

| Flag | Meaning |
|---|---|
| `--output-format <fmt>` | `stream-json` (newline-delimited events, then the final `result` envelope), `json` (a single buffered `result` object), or `text` (human, default). Supersedes `--format`. |
| `--format json` | Deprecated alias of `--output-format stream-json` — output unchanged. Emits newline-delimited events, then one final `result` envelope (`ok`, `steps`, `durationMs`, `messages`, `tokens`, `cost`) — written on the failure path too. |
| `-r <title>` / `--resume-title <title>` | Resume the most recent session whose title matches `<title>`. |
| `--tools <a,b>` | Restrict the advertised tool set to these ids (an unknown id is an error). |
| `--disallowed-tools <a,b>` | Remove these ids from the advertised set. |
| `--allow <Tool(pat),…>` | Append `allow` permission rules for the run. |
| `--deny <Tool(pat),…>` | Append `deny` permission rules (last-match-wins; a `--deny` beats an overlapping `--allow`). |
| `--append-system-prompt <text>` | Append text to the run's system context. |
| `--json-schema <path\|json>` | Structured output: validate the model's final JSON against this JSON Schema (a file path or inline JSON). Threads the schema into the prompt, re-prompts with the validation errors up to a bounded retry cap. On success the validated object is the result — printed bare in text framing, or as `result.structured` on the `--format json` result envelope (exit 0). An unsatisfiable or uncompilable schema exits **6** with the typed error; a run cut short keeps its typed exit code (budget 3 / time 4 / no-progress 5 / permission 2 / interrupt 130). |
| `--continue` / `-c` | Continue the last session. |
| `--session <id>` / `-s` | Continue a specific session. |
| `--fork` | Fork the session before continuing (needs `--continue` or `--session`). |
| `--agent <name>` | Start in a specific agent. |
| `--model <provider/model>` / `-m` | Use a specific model. |
| `--directory <dir>` | Working directory (defaults to the current one). |
| `--db <path>` | Session database file. |
| `--atif <path>` | Write the session's ATIF-v1.8 trajectory (JSON) to `<path>` at the end of the run — the same document `oceancode export --atif` produces. Empty/unset = off. See [06 · Session ledger and export](#exporting-a-session). |
| `--subagent <provider/model>` | `/subagent` for a headless run: the sub-agent model this session's children run (e.g. `openai/gpt-5-mini`). Written onto the session (survives `--continue`). A value that is not `provider/model` is refused with the same words `/subagent` uses. Empty/unset leaves whatever the session already carries. |

Bound flags (`--max-usd`, `--max-minutes`, `--max-steps`, `--tideline`, `--subagent`) must come **before** the
prompt — the parser stops at the first non-flag word, and a bound placed after the prompt is
ignored with a warning. `--tideline <pct>%` sets the tideline as a share of the model's
context (e.g. `--tideline 50%`); `--tideline <limit>` or `default` also work. The floor and the
presets were removed: a second number or a preset word is refused with the usage line.
The old `--window` name is refused with the hint `use --tideline`; `--shelve` still works as an alias. See
[08 · Bounding a long run](#bounding-a-long-run).

The engine also serves `GET /session/{id}/subagent` → `{"model": <ModelRef> | null}` — the session's
effective sub-agent model (the `/subagent` override, else the fleet router's `CLAUDE_CODE_SUBAGENT_MODEL`
while the subscription slot is live, else the `subagent.model` config, else the session's own model). A client
reads it for the identity row's `sub-agents <name>` segment; an engine without the route answers as
"nothing to show".

### Host-run workflows — `oceancode workflow`

Launch and control a host-run Rhai workflow without a UI. `workflow run` boots the engine the
same way `oceancode run` does, launches the workflow, streams each `workflow_updated` body as
one JSON line on stdout, and exits by the run's outcome.

```bash
oceancode workflow run pipeline --args '{"steps":[{"prompt":"say A"},{"prompt":"say B"}]}'
oceancode workflow run ./research.rhai --budget 64 --model openai/gpt-5-mini
oceancode workflow run pipeline --validate-only --args '{"steps":[{"prompt":"x"}]}'
oceancode workflow runs --all
oceancode workflow stop wf-…
```

The workflow **name (or a path to a `.rhai` file) comes first**, before any flags. A registered
name (a built-in such as `pipeline`, or a discovered `.oceancode/workflows/<name>.rhai`) is
resolved by the launcher; a path you type is read and run as an inline script.

| Flag (of `workflow run`) | Meaning |
|---|---|
| `--args <json>` | A JSON object bound to the script's `args` global (e.g. a pipeline's `steps`). Empty = `{}`. |
| `--budget <n>` | Absolute cap on cumulative child-agent calls for this run, `1..=1024`. Unset uses the `workflows.agent_budget` config default. |
| `--validate-only` | Run a metadata + compile smoke check only — prints a `validated` body and launches no agent (no provider call). |
| `--model <provider/model>` / `-m` | Fill this model in for workflow steps that specify none (a pipeline's `args.steps[].model`, else top-level `args.model`). It never overrides a model a step already carries, and there is **no default provider** — a step left unset follows the session policy. |
| `--directory <dir>` | Working directory the run and its sub-agents use (defaults to the current one). |
| `--db <path>` | Session database file. |
| `--verbose` / `-v` | Trace outbound LLM provider calls to stderr. |

**Exit codes** (the run's outcome): **0** `complete`; **2** `failed`, `cancelled` or
`budget_limited`; **3** a pause (`user_paused`, `back_off_paused`, `no_progress_paused`,
`infra_paused`, `blocked`). The last streamed line always carries the terminating body, so a
script can read the outcome from stdout as well as from `$?`.

`workflow runs [--all]` lists the persisted runs (each `workflow_updated` body, one per line),
read from the run directories under the app data dir — scoped to the current project unless
`--all` lifts the filter. `workflow pause|resume|stop <run_id>` drive the launcher's control
seam; `resume` takes an optional `--budget <n>` to lift a budget-limited run. These control a
run **live in the engine this command boots** — a run launched by a separate `oceancode
workflow run` has already exited in its own process, so cross-process control is a shared-server
concern not wired here.

### Goal mode — `oceancode goal`

Prove an objective is done, not merely claim it — headless. `oceancode goal "<objective>"` boots
the engine like `oceancode run` does, launches the built-in `goal` workflow (**Plan → Work →
Verify**: plan the objective into verifiable deliverables, work in rounds with one resumed
worker, and converge only when a cold panel of adversarial skeptics fails to refute completion),
streams each `goal_updated` body as one JSON line on stdout, and exits by the run's outcome. It is
the headless twin of the pager's `orchestrate: always` goal mode (`19-pager.md`).

```bash
oceancode goal "add a --json flag to the status command and prove it works"
oceancode goal --budget 64 --model openai/gpt-5-mini "port the parser to the new API"
```

The **objective is the positional argument** — quote it, or let the words after the flags form
it. Go-style flag parsing stops at the first positional, so any flag must come **before** the
objective.

| Flag | Meaning |
|---|---|
| `--budget <n>` | Absolute cap on cumulative child-agent calls for the whole goal, `1..=1024`. Unset uses the `workflows.agent_budget` config default. |
| `--model <provider/model>` / `-m` | Run-level default model: pins every one of the goal's agents (planner, worker, skeptics) whose own step set no model. There is **no default provider** — unset, each agent follows the session policy. |
| `--directory <dir>` | Working directory the goal and its sub-agents use (defaults to the current one). |
| `--db <path>` | Session database file. |
| `--verbose` / `-v` | Trace outbound LLM provider calls to stderr. |

**Exit codes** (the run's outcome): **0** `complete`; **2** `failed`, `interrupted` (a stopped
run) or `budget_limited`; **3** a pause (`user_paused`, `back_off_paused`, `no_progress_paused`,
`infra_paused`, `blocked`) — a goal that stalls on identical gaps two rounds running, or spends
its budget, pauses rather than looping forever. The last streamed line always carries the
terminating body, so the outcome reads from stdout as well as from `$?`. A finished goal is also
listed by `oceancode workflow runs --all` (it shares the workflow run store).

### Long-lived server — `oceancode serve`

```bash
oceancode serve                 # loopback, port 4096
oceancode serve --port 8080
```

| Flag | Meaning | Default |
|---|---|---|
| `--port <n>` | TCP port to listen on. | `4096` |
| `--host <ip>` | Interface to bind. Loopback by default; a non-loopback host refuses to start without `OC_SERVER_PASSWORD`. | `127.0.0.1` |
| `--db <path>` | Session database file. | `oceancode.db` |
| `--directory <dir>` | Working directory. | current directory |
| `--verbose` / `-v` | Trace outbound LLM provider calls. | off |

The default shape (`oceancode`, the pager) passes `--listen <host:port>` through to the engine it
embeds; a non-loopback bind refuses to start without a server password (below). The guard is
enforced inside the server for both `oceancode --listen` and `oceancode serve --host`.

### Authentication

HTTP basic auth is off until you set a password.

| Variable | Meaning | Default |
|---|---|---|
| `OC_SERVER_PASSWORD` | The basic-auth password. Empty means auth is disabled; any value turns it on for every route, SSE included. | unset (off) |
| `OC_SERVER_USERNAME` | The basic-auth username. | `oceancode` |

A non-loopback bind (`--listen` on the default shape, or serving to a public interface) refuses
to start unless `OC_SERVER_PASSWORD` is set.

### Managing capabilities from the CLI

Besides `run` and `serve`, the binary answers a set of non-interactive management
subcommands that boot no session. Each is read-only or writes a single config file, and exits.

| Subcommand | What it does |
|---|---|
| `inspect [--json]` | Print the provenance of every config key, capability, MCP server and instruction file for the current directory. See [09 · Capabilities and plugins](#capabilities-and-plugins). |
| `doctor` | Report crash-recovery state: orphaned sessions (registry entries whose process died without a clean exit) and captured panic reports found under the data dir; prints `no orphaned sessions` when clean. Read-only. See [12 · Permissions and sandbox](#managed-enterprise-config). |
| `plugin …` | Manage plugins and marketplaces: `marketplace add\|list\|update\|remove`, `install`, `list`, `enable`/`disable`, `trust [--off]`, `validate`, `update`/`uninstall`, and `import --from claude-code [--trust] [--no-repo-trust] [--dry-run]` (copy Claude Code's plugins, register its marketplaces, seed trust). See [09 · Capabilities and plugins](#importing-from-claude-code). |
| `mcp …` | Manage MCP servers: `add` (`--command`/`--url`), `list` (every discovered server with `name  kind  tier  trusted  enabled  source`), `enable`/`disable`, `remove`, `login`/`logout`, `doctor`. See [11 · MCP servers](#mcp-servers). |
| `skill …` | `skill create <name> [--description <text>]` scaffolds `.oceancode/skills/<name>/SKILL.md`; `skill list [--json]` prints every discovered skill with its tier, trust and enable state. See [09 · Capabilities and plugins](#skills--browse-and-run). |
| `hooks …` | `hooks list [--json]` prints every hook source file the boot loader reads (tier, trust, loaded or held and why) with its handlers and ends with `HOOKS_OK sources=<n> handlers=<m> loaded=<k>`; `hooks add <Event> --command <cmd> [--matcher <re>] [--timeout <s>] [--scope user\|project\|local]` and `hooks remove …` edit the same `settings.json` files Claude Code writes. See [10 · Hooks](#hooks-and-oceancode-hooks-list). |
| `jev status [--json] \| probe [--record …] \| labels \| questions --json \| verdicts --in <file>` | The Jev decision layer: each decision point's mode and endpoint host (`JEV_STATUS_OK points=7`), a one-call live probe (`JEV_PROBE_OK`) and fixture recorder (`probe --record`, `JEV_RECORD_OK`), the export of every labelled decision from the per-session `jev-decisions.jsonl` files into `bench/jev/labels.json` (`JEV_LABELS_OK`), and the seams' question sets and policy verdicts for the bench. Reads the current directory and your environment (no `--dir`/`--home`); only `probe` calls Jev. See [13 · Jev decisions](#the-oceancode-jev-subcommand). |
| `trust …` | The persisted per-project trust record (`~/.oceancode/trust.json`): `show` (ends with `TRUST_OK`), `repo on\|off`, `mcp <name> on\|off\|clear`. See [11 · MCP servers](#held-until-trusted). |
| `migrate [--db <path>]` | Open and migrate a session database, then exit — the standalone form of the boot-time schema upgrade (`--db` defaults to `oceancode.db`). |
| `update [--self-test]` | Self-update to the newest release for the configured channel: verify the release signatures, swap `oceancode` and `ocean-pager` atomically, roll both back on a failed health check. `--self-test` proves the pair swap+rollback offline and prints `UPDATE_OK from=<old> to=<new> rollback_tested=1` then `UPDATE_SELF_TEST_OK binaries=2`. Channel/opt-out via `update.*`, `OC_UPDATE_CHANNEL`, `OC_NO_UPDATE_CHECK`. See [17 · Distribution and updates](#distribution-and-updates). |
| `verify-release <artifact> [--pubkey <base64>] [--sha256sums <path>]` | Verify a release artifact's minisign `.minisig` signature (and, with `--sha256sums`, its `SHA256SUMS` entry) in-process — no external tool. Prints `VERIFY_RELEASE_OK file=<name>`. The portable trust check on macOS, Linux and Windows. See [17](#verifying-a-download). |
| `uninstall [--purge] [--yes] [--dry-run]` | Remove the binary the installer placed; `--purge` also removes the app's config and data dirs. Requires a typed `oceancode` confirmation unless `--yes`; `--dry-run` reports the plan and removes nothing. Prints `UNINSTALL_OK removed=<n>`. See [17](#uninstalling). |
| `login [--provider claude\|copilot\|codex] [--no-browser]` | Log in and store the credential in `auth.json`. `claude` (default) runs the Claude Pro/Max subscription OAuth; `copilot` and `codex` run the OAuth 2.0 device-code flow. See [01 · Getting started](#getting-started). |
| `logout [provider]` | Clear the stored credential for a provider (default `anthropic`) from `auth.json`; the counterpart to `oceancode login` / `oceancode auth`. See [01 · Getting started](#getting-started). |
| `claude` | Preflight the claude-code provider: report whether the Claude Code CLI is installed and logged in. Advisory only — it never blocks a boot. |

Every management subcommand takes `--dir <d>` (the project; default: the current directory) and
`--home <h>` (the home the user tier and `~/.oceancode` are read from; default: your home) so a
script — or a test — can point it at another tree.

### Permission mode

A client reads and switches the session's permission mode over HTTP — the boot
default is the `permission_mode` config key, and these two routes change it live
(the pager switches it from its own prompt):

| Route | Meaning |
|---|---|
| `GET /api/session/{id}/permission/mode` | Returns `{ mode, bypass_locked }` for the session. |
| `POST /api/session/{id}/permission/mode` | Set the mode; body `{ "mode": "acceptEdits" }`. An unknown mode is `400`; `bypassPermissions` while a managed requirements file has locked it is `409`. |

The four modes are `default`, `acceptEdits`, `dontAsk`, `bypassPermissions` — see
[12 · Permissions and sandbox](#permission-modes).

### Managed (enterprise) configuration

For a shared or locked-down deployment, an administrator layers a managed config
and a signed requirements file over every project:

- `OC_MANAGED_CONFIG` — path to the managed config file (default: the OS admin
  path). It is loaded last, so it overrides the project and global files.
- `OC_REQUIREMENTS_PUBKEY` — the trusted ed25519 public key (64 hex chars) that a
  `requirements.json` signature is checked against; an unsigned or tampered file
  is refused. With no key set, managed requirements are not enforced.

Both are described in [02 · Configuration](#configuration); the requirements
file can disable `bypassPermissions`, allow-list MCP servers, and pin config keys.
See [12 · Permissions and sandbox](#managed-enterprise-config).

### See also

- [01 · Getting started](#getting-started) — the three boot shapes.
- [02 · Configuration](#configuration) — the server variables in one table.

---

## Bounding a long run

A run is bounded by the **money and time you set**, not by an arbitrary step count. Set nothing
and a run keeps going until the work is done (or the no-progress guard stops it, below).

### The bounds

| Bound | Environment variable | `oceancode run` flag | Slash command (this session) |
|---|---|---|---|
| Spend cap (USD) | `OC_MAX_USD` | `--max-usd 25` | `/budget 25` |
| No spend cap | — | `--max-usd off` | `/budget off` |
| Subscription (no cap; spend is a list-price estimate) | — | `--max-usd subscription` | `/budget subscription` |
| Wall clock | `OC_MAX_MINUTES` | `--max-minutes 8h` (also `45`, `90m`, `2d`, `off`) | `/limits time 8h` |
| Steps per turn (root), 1–1000 | `OC_MAX_STEPS` | `--max-steps 100` (`default` clears) | `/limits steps 100` |
| Steps per sub-agent turn | `OC_MAX_SUBAGENT_STEPS` | — | — |
| Tideline (% of context) | `ledger.tideline.percent` (config) | `--tideline 50%` | `/tideline 50%` |
| Tideline (fixed tokens) | `ledger.tideline.tokens` (config) | `--tideline 60k` | `/tideline 60k` |
| Tideline block (what one cut removes) | `ledger.tideline.block` (config: a token count, or a share of the line as `"35%"`; unset = 70 % of the line) | — | — |

**Precedence:** slash command / flag > environment variable > built-in default. Flag and slash
values are stored on the session, so they survive `--continue`.

The tideline's old names still work as aliases: `--shelve`, `/shelve` and `ledger.shelve`.

### The step cap is a boundary, not a stop

`OC_MAX_STEPS` (default 300) caps the steps in one root turn, but a root turn that reaches it
**auto-continues** into a fresh turn — no budget or clock needs to be set. The ledger cuts by
blocks after every step, so even one long turn stays under the tideline (see
[06 · Session ledger and export](#the-tideline--tideline)). `0` is
rejected: bound a long run with a budget or a clock instead.

Sub-agents are different: they simply **stop** at `OC_MAX_SUBAGENT_STEPS` (default 300), so a
raised root cap does not deepen them.

### Output tokens

`OC_MAX_OUTPUT_TOKENS` overrides the per-request output-token cap in either direction, clamped
to the model's own published maximum. It bounds how much a single model response may emit, not
the run as a whole; leave it unset to use the model's full maximum.

### What happens at a bound

Exceeding a bound is a **clean, non-error stop**, distinct from a crash, and the ledger records
it as a step-limited turn. The internal stop reasons are `BudgetExceeded` and `TimeExceeded`.
Over the Ocean tunnel the response carries a typed `stopped_by`:

| Bound reached | `stopped_by` |
|---|---|
| `OC_MAX_MINUTES` / `/limits time` | `time-exceeded` |
| `OC_MAX_USD` / `/budget` | `budget-exceeded` |
| step cap | `step-limit` |

### The plan drives the run

The model keeps its plan as a todo list (see [05 · Plan mode and todos](#plan-mode-and-todos)).
A root turn that settles while any todo is still open does **not** end the run — the engine
works off the next open todos and drains again. A run ends only when:

- the work settles with no open todo; or
- a spend or clock bound trips; or
- you interrupt it; or
- the **no-progress guard** fires — two consecutive continued turns that leave the open and
  completed counts unchanged stop the run cleanly with a "Run stopped" toast.

So a model that never plans and never settles runs a few turns and stops; one that plans and
makes progress runs until its list is done or a bound trips.

### The Jev supervisor (opt-in)

With the Jev decision layer on (see [13 · Jev decisions](#jev-decisions)), a supervisor
reads a root turn's clean finish and, every `jev.supervisor_every_steps` settled steps (default
4), the last six steps. With `jev.supervisor = "act"` it may keep a finish from ending the turn —
a synthesized continuation, at most `jev.supervisor_max_continuations` per human prompt
(default 1), only when the goal is clearly not complete and progress is real — and may send one steer message when the steps repeat without
progress. It never stops a run and never moves a bound: the spend, clock and step bounds above,
the exact-repeat doom-loop trip, the hook cap and the no-progress guard decide exactly as they do
without it. In `shadow`, the default once the layer is enabled, it only records what it would
have done.

### Stopping a run stops all of it

`esc` `esc` (or the palette's abort) stops the session's own drain at its next step boundary,
and every sub-agent stops with it — foreground ones immediately, background ones cancelled by
the parent. A late background result is recorded as cancelled and never restarts the run.

### Background jobs and long waits have no clock of their own

A job the model starts — `bash` with `background: true`, a `monitor`, a `loop`, a `scheduler` —
runs until it ends or the model calls `job_kill`. It dies with the run only when the run has a
wall clock (`OC_MAX_MINUTES` / `/limits time`); without one a build, a watch or a server runs for
hours or days, and `job_output` reads it back whenever the model asks. (Until 2026-10-05 every
job was reaped ten minutes after launch while the run had no clock at all.)

A long wait does not lose the prompt cache either. Every request's cache markers carry the
provider's one-hour TTL, and while a step waits — on a sub-agent, on a long `bash` — or the
session idles, the engine re-reads the last request every `OC_CACHE_KEEPALIVE_MINUTES`
(default 50) so that clock restarts: a cache read of the prefix (`session.cache.touched`, cents)
instead of re-writing it when the wait ends. It stops after `OC_CACHE_KEEPALIVE_MAX` touches (default 48, ≈ 40 h; past a model's break-even — 80 touches on Fable 5.1, 20 on a 0.1× model — a re-write is the cheaper bet). A touch that fails is sent once more a few minutes later before the keep-alive gives up, and pressing Esc on a running turn does not stop it — the next prompt reads the same prefix. See [02 · Configuration](#configuration).

### See also

- [02 · Configuration](#configuration) — the `OC_MAX_*` variables in one table.
- [07 · Headless and server](#headless-and-server) — the `--max-*` flags on `oceancode run`.

---

## Capabilities and plugins

Skills, sub-agents, personas, commands and MCP servers are all **capabilities**, and `oceancode`
finds them the same way: one discovery walk over five tiers, across four ecosystems, with a
single rule for names that collide and a separate rule for what a capability is allowed to do.

### The five tiers

A capability is discovered from one of five tiers. The walk runs from the narrowest scope out to
the widest, so a name defined closer to the code wins.

| Tier | Where it comes from | Example root |
|---|---|---|
| Local | The project's own `.oceancode/` — your machine's private copy, git-ignored | `.oceancode/skills`, `.oceancode/agents` |
| Repo | The project you are in, checked into the repo | `.claude/`, `.opencode/`, `.grok/` |
| User | Your home directory, read across every ecosystem | `~/.oceancode/…`, `~/.claude/…`, `~/.grok/…` |
| Bundled | Shipped inside the binary | (built in) |
| Plugin | An installed plugin's own tree | `<plugin>/skills/`, `<plugin>/agents/` |

Plugin-tier roots are enumerated from disk — one root per installed plugin directory — so a
plugin contributes its skills, agents and hooks only while it is installed. For plugins Claude
Code installed, the roots are **exact**: only the version directories its
`~/.claude/plugins/installed_plugins.json` records for the user scope, or for this project, are
walked (one root per plugin even when the file lists the same install twice), and a plugin you
have disabled in `~/.claude/settings.json` (`enabledPlugins`) stays disabled here. A project's
own `.claude/settings.json` can never re-enable a plugin you disabled.

The `.oceancode/` directory in a project is the **local** tier — your machine's private copy,
trusted precisely because it is not shared repo content — and is git-ignored in this monorepo.

### Four ecosystems

Each tier is read for four ecosystems at once, so a capability written for another tool is picked
up without conversion:

- **OceanCode** — the native `.oceancode/` layout.
- **Claude Code** — `.claude/` (skills, agents, rules, `settings.json`).
- **OpenCode** — `.opencode/` (`opencode.json`, agents).
- **Grok Build** — `.grok/` (agents, personas, rules, memory).

Frontmatter and directory conventions from each ecosystem are translated into the one internal
shape as they are read; you do not have to rewrite a Claude Code skill or a Grok persona to use
it here.

Rule files may pull in other files with `@path` imports (up to four hops); an import is bounded to
the project directory and your home directory, so a rule can never inline a file from elsewhere on
disk.

A rule may scope itself to a file glob in its frontmatter — `paths: "**/*.py"` (Claude Code / OpenCode)
or `globs: "**/*.ts"` (a Cursor `.mdc`). A scoped rule is loaded only when the working tree actually
contains at least one file matching its glob; in a checkout with no match it is left out entirely, so a
TypeScript project does not carry Python or Ruby rule bodies. The check honours your ignore rules
(`.gitignore`, `.git` pruned) and runs once per session. Scope today is resolved for the whole checkout,
not per individual file the current turn touches. A rule with no `paths`/`globs` is always loaded — with
one Cursor exception: a `.mdc` under `.cursor/rules` that says `alwaysApply: false` and has no `globs` is
Cursor's agent-requested or manual rule, which Cursor attaches only when asked, and the engine leaves it
out too (a `globs:` rule keeps its scope whatever the flag says; the key means nothing outside
`.cursor/rules`).

### Workflow scripts

Workflow scripts are discovered the same way, from two OceanCode roots in the usual precedence
(**built-in > project > user**). Every `*.rhai` file under a root is one workflow, identified by
the `meta.name` its script declares — not by its file name:

| Scope | Root | Files |
|---|---|---|
| Project | `.oceancode/workflows` | `*.rhai` |
| User | `~/.config/oceancode/workflows` | `*.rhai` |

Two workflows ship **built in** to the binary — the highest tier — so they run by name with no
`*.rhai` file on disk. Built-ins **cannot be shadowed**: a project or user script with the same
`meta.name` as a built-in is ignored (dropped from the picker catalog with a warning), and the
built-in still runs — that is what "built-in > project > user, first wins" means. A duplicate
`meta.name` across two discovered files likewise keeps the first (project before user).

| Name | Args | What it does |
|---|---|---|
| `deep-research` | `query` (or `objective`); `breadth` 2–6, default 4 | Researches the query with bounded parallelism across four phases — Plan, Research, Verify, Report — independently cross-checks every candidate claim, and writes a cited report. |
| `goal` | `objective`; `skeptics` 1–5, default 3; `max_rounds`, default 10; optional `worker_model`, `verifier_model` | Proves an objective is done, not just claimed done: plans it into verifiable deliverables, works in rounds with one resumed worker, then has a panel of adversarial skeptics try to refute completion. Converges only when a strict majority of the cold panel fails to refute; pauses on identical gaps two rounds running or a spent agent budget. |
| `pipeline` | `steps: [{ prompt, label?, model?, agent_type?, output_schema? }]`; `stop_on_failure`, default true | Runs an ordered list of agent steps one after another; each step is given the previous step's output. Stops at the first failed step unless `stop_on_failure` is false. |

### Names that collide are qualified; unique names stay bare

A capability keeps its bare `name` when that name is unique across everything discovered. Only
when the same name appears more than once is it **qualified**, and only the colliding members are
rewritten:

| Situation | Qualified name |
|---|---|
| Same name at two different tiers | `tier:name` (e.g. `repo:review`) |
| Same tier, different ecosystems | `tier.<ecosystem>:name` (e.g. `repo.claude_code:review`, `repo.open_code:review`) |
| A plugin tier | `<plugin>:name` |
| A residual same-tier, same-ecosystem tie | `tier.<ecosystem>.<root-dir>:name` |

The pass reads only each item's bare name and source, so it is idempotent — running discovery
again yields the same qualified names in the same order.

The **slash name** of a plugin skill is always `<plugin>:name`, even when the name is unique —
Claude Code's convention, so `/superpowers:writing-plans` lists and runs in the Ocean pager's `/`
popup exactly as it does there. Typed as a prompt, `/<plugin>:name` and the bare `/name` both run
an enabled plugin skill; a `<plugin>:` prefix that names no installed plugin does not resolve (a warning toast; the text is then sent as a normal message).

### Enable is not trust

Two independent gates decide what a capability does:

- **Enabled** — whether the capability is discovered and offered at all. Disabling a plugin
  removes its capabilities from the walk — including its rule bodies, which no longer inject
  into the system prompt (its skills, agents, commands, hooks and MCP servers drop too).
- **Trusted** — whether a capability that can *execute code on your behalf* is allowed to run
  without a prompt. **Only hooks and MCP servers gate on trust**; a skill or a persona is inert
  data and needs no trust decision.

By default, Local, User and Bundled tiers are trusted; the Repo tier is untrusted (code that
arrived with a checkout has not been vetted by you); a Plugin is trusted only if you installed it
with trust. So enabling a repo-supplied hook is not the same as trusting it — it is discovered,
but it will not run its command until you trust it.

Trust decisions for a project persist in `~/.oceancode/trust.json`, keyed by the project's
absolute path: `repo` (may the repo tier's hooks and `.mcp.json` servers execute?), `mcp`
(per-server overrides that beat `repo`) and `disabled_mcp` (servers held disabled at any tier).
Plugin trust lives beside the plugin in `~/.oceancode/plugins/installed.json`. Set them with
`oceancode trust repo on|off`, `oceancode trust mcp <name> on|off|clear`, `oceancode plugin
trust <name> [--off]`, or from the `/hooks`, `/mcp` and `/plugins` pickers; read them with
`oceancode trust show`.

### Enabling and disabling skills

Loading every skill from every tier costs prompt tokens — one `<available_skills>` line and one
`skill_*` tool per skill, plus a disabled plugin's rule bodies. Skills, plugins and MCP servers
share **one enable interface** so you can turn off what you are not using; a disabled skill costs
nothing (it is not in `<available_skills>`, registers no `skill_*` tool, and its rules do not
inject).

A skill's effective state is the fold of four tiers, resolved in this order — the first that
turns it off wins, so a **project can disable a skill but can never re-enable one you disabled**:

| # | Tier | Turned off by | `skill list` reason |
|---|---|---|---|
| 1 | Plugin | The skill's plugin is disabled (`oceancode plugin disable`) | `disabled (plugin)` |
| 2 | User | You disabled the skill (`~/.oceancode/skill-state.json`) | `disabled (user)` |
| 3 | Project | `skills.disabled` in `.oceancode/oceancode.json` | `disabled (project)` |
| 4 | Allowlist | `skills.mode = "allowlist"` and the skill is not in `skills.enabled` | `not in allowlist` |

The per-skill **user** toggle is written by the CLI (or the `/skills` picker) into
`~/.oceancode/skill-state.json` — OceanCode's own store, never Claude's `~/.claude/settings.json`.
The **project** policy (`mode`, `disabled`, `enabled`) lives in `.oceancode/oceancode.json` under
`skills` (see [02 · Configuration](#configuration)); default mode `all` keeps every skill a
candidate. The project policy is a **default**: an explicit enable of one skill (`Space` on its row,
a plugin-group header's `Space`, or `oceancode skill enable <id>`) is also recorded for this project
as `enabled_skills` in `~/.oceancode/trust.json` and beats the project's `skills.disabled` and
allowlist (tiers 3–4), so a skill the allowlist leaves out can be switched on without editing the
config; it never beats the master switch or a disabled plugin, a disable drops it again, and
`enable-all` stays user-tier only. MCP keeps its own `disabled_mcp` / `enabled_mcp` model
(`oceancode mcp enable|disable`, see [11 · MCP servers](#mcp-servers)); the verbs are the same shape.

```bash
oceancode skill enable <id>            # clear the user-tier disable; beats the project policy
oceancode skill disable <id>           # disable this skill at the user tier
oceancode skill enable-all             # clear every user-tier skill disable
oceancode skill disable-all            # disable every currently-discovered skill
```

`<id>` is a skill's **stable id** `<tier>.<eco>:<name>` — e.g. `user.claude_code:agents-sdk`,
`local.ocean_code:tdd`, `repo.open_code:deep` — shown in the `id` column of `skill list`. It is
computed the same way whether or not the name collides with another tier, so the store never loses
a disable when a session sees only one tier. You may also pass a **bare name** when exactly one
discovered skill has it (`oceancode skill disable wrangler`); a name shared by more than one skill
is refused with the candidate ids, so pass the full `id` to pick one. A bare name is still accepted
in `skills.disabled`/`skills.enabled` and as a legacy store entry (it matches every copy of that
name). If you disabled skills before this change, re-run `oceancode skill disable-all` once to
rewrite the store with stable ids.

A toggle takes effect **on the next step** of the running session. The `/skills` and capabilities
pickers, the `/skill` invocation gate and the Ocean pager's composer `/` completion update at once
(an enabled skill is offered as `/<skill>`, a disabled one is not); the `<available_skills>` block,
the `skill_*` tools, the MCP tool list and the hook dispatcher are rebuilt before the agent's next
model call, so the very next request no longer carries what you turned off. A step already running
finishes with what it started with. Each change made mid-session costs **one system-prompt cache
rewrite** (the cached prefix changes once), so flip several things together rather than one per
turn. A change made from a shell (`oceancode skill disable-all`) lands on the next step too.

**Whole groups and everything at once.** In the Ocean pager's capabilities picker (`/skills`,
`/plugins`, `/hooks`, `/mcps`) `Space` works at four levels:

| Row under the cursor | `Space` toggles | Asks first |
|---|---|---|
| A skill, plugin, hook or MCP server | that one item | no |
| A `Plugin: <name>` header (Skills, MCP tabs) or a hook source header (Hooks tab) | every row in the group | above 10 rows |
| `All skills` / `All plugins` / `All MCP` / `All hooks` (second row of the tab) | the whole kind | yes |
| `Everything` (first row of every tab) | the master switch | yes |

`Everything` off removes skills, MCP tools, hooks and plugin rules from the agent's context; the
built-in tools (bash, read, edit, …) stay. Turning it back on restores exactly the per-item state
you had. A group whose rows are all off shows `[off]` on its header, and the picker's last row
shows the live counts — `skills 22/259 · plugins 2/21 · mcp 1/10 · hooks 8/8` — with
`EVERYTHING OFF` in front while the master switch is off. Group and master state is per project,
stored next to `disabled_mcp` in `~/.oceancode/trust.json`; per-skill disables stay in
`skill-state.json`. The Plugins tab lists Claude Code's cached plugins (under `User (Claude)`)
beside the OceanCode-store ones, and `Space` turns a whole plugin — its skills, hooks, MCP servers
and rules — off or on.

**Start from nothing.** To strip the prompt down and add capabilities back deliberately, disable
everything and re-enable one by one: `oceancode plugin disable-all` (then re-enable from
`/plugins`), and `oceancode skill disable-all` with `skills.mode = "allowlist"` + a short
`skills.enabled` list for the handful of skills you actually want. With every plugin disabled and
an empty allowlist, the first turn carries no `<available_skills>` block and no `skill_*` tools at
all.

### `oceancode inspect` — where did this come from?

To see the winning source of every capability, config key, MCP server and instruction file for
the current directory:

```bash
oceancode inspect          # a human-readable provenance table
oceancode inspect --json   # a stable JSON array (for scripts)
```

Each row names the kind, the id, the layer that won, and the file it came from — the fastest way
to answer "why is *this* skill loaded and not that one?". It boots nothing and is read-only.

`instruction` rows list what the directory contributes to the system prompt: one primary file —
`CLAUDE.md`, else `AGENTS.md`, never both, so a generated `AGENTS.md` mirror is not loaded twice —
then `CLAUDE.local.md` and `CONTEXT.md` when present. The same rule applies at every level from the
worktree root down to the working directory, and to the reminder that fires when a tool first touches
a directory this session. The system prompt is fixed for the rest of a prompt, so an edit to these files
(or a URL instruction expiring, or midnight passing) made while a prompt runs applies from the next
prompt, not the next step; switching a capability or the model still applies on the very next step.

### Plugins and marketplaces

A plugin bundles skills, agents and hooks under one installable tree. Manage them from the CLI:

```bash
oceancode plugin marketplace add <spec>      # register a marketplace
oceancode plugin marketplace list
oceancode plugin marketplace update <name>
oceancode plugin marketplace remove <name>

oceancode plugin install <name-or-path> [--trust] [--marketplace <m>]
oceancode plugin list
oceancode plugin enable <name>               # / disable <name>
oceancode plugin enable-all                  # / disable-all — flip every installed plugin
oceancode plugin trust <name> [--off]        # grant / revoke trust
oceancode plugin validate <name>             # check the manifest
oceancode plugin update <name>               # / uninstall <name>
```

`install` without `--trust` enables the plugin but leaves it untrusted, so its hooks and MCP
servers stay dormant until you run `plugin trust`. Pass `--root <dir>` to any `plugin` subcommand
to point at a project other than the current directory.

In Ocean pager, `/plugins` opens the Plugins tab of the capabilities picker; see
[19 · Ocean pager](#what-is-wired-to-the-ocean-engine).

### Importing from Claude Code

If you already use Claude Code, one command brings its setup here:

```bash
oceancode plugin import --from claude-code --dry-run   # show what would happen, write nothing
oceancode plugin import --from claude-code --trust     # do it, trusting the imported plugins
```

What it does, and what it deliberately does not:

- **Plugins are copied** from Claude Code's cache (`~/.claude/plugins/cache/<marketplace>/<name>/<version>/`)
  into `~/.oceancode/plugins` — only the versions installed for the user scope or for this
  project — and each is enabled or disabled as your `~/.claude/settings.json` says. Without
  `--trust` they are enabled but untrusted, so their hooks and MCP servers stay dormant.
- **Marketplaces** Claude Code knows (`known_marketplaces.json`) are registered.
- **Trust is seeded** from Claude Code's own record in `~/.claude.json`: the project's
  `hasTrustDialogAccepted` becomes `repo=true` (pass `--no-repo-trust` to opt out), servers in
  `disabledMcpServers` / `disabledMcpjsonServers` are held disabled, `enabledMcpjsonServers` are
  trusted.
- **Skills, commands, agents, rules, hooks and MCP configs at the user and repo tiers are not
  copied.** `~/.claude/skills`, `~/.claude/commands`, `~/.claude/settings.json`, `.claude/**` and
  `.mcp.json` are read where they are, so they stay in sync with Claude Code.

Every line the command prints names one action (`IMPORT`, `SKIP`, `MISSING`, `MARKETPLACE`,
`TRUST`); the last line is `IMPORT_OK plugins=<n> marketplaces=<m> repo_trust=<bool>` (with
`--dry-run`, `IMPORT_DRY_RUN_OK …`). A cache tree that is not a plugin — Claude Code's
`rust-analyzer-lsp` / `typescript-lsp` entries carry neither a manifest nor a `SKILL.md` — is
reported `SKIP <name> not a plugin` and one entry that fails to install is reported
`SKIP <name> install failed: <why>`; neither stops the run, so the trust seeding at the end always
happens. Nothing under `~/.claude` is ever modified.

### `/skills` — browse and run

Every discovered skill — yours, the project's, a plugin's, from any of the four ecosystems — is a
slash command: `/<name> <arguments>` runs it as a command turn whose prompt is the skill body
prefaced by a one-line instruction, with `$ARGUMENTS` and the positional rules applied. A name
that collides across tiers is invoked by its qualified form (`/repo:review`). The completion popup
lists skills with the other commands.

In Ocean pager, `/skills` opens the Skills tab of the capabilities picker and the composer's `/`
completion lists your enabled skills; see
[19 · Ocean pager](#what-is-wired-to-the-ocean-engine). Without a session:

```bash
oceancode skill list            # id  tier  trusted|untrusted  <state>  description
oceancode skill list --json     # each row carries "id", "enabled" and a "reason" tag when disabled
```

The `id` column is the stable `<tier>.<eco>:<name>` id (`--json` also keeps the collision-resolved
`qualified` name); copy it straight into `oceancode skill enable|disable <id>`.

The state column reads `enabled`, or why the skill is off — `disabled (plugin)`,
`disabled (user)`, `disabled (project)` or `not in allowlist` (see
[Enabling and disabling skills](#enabling-and-disabling-skills)).

### See also

- [10 · Hooks](#hooks) — the one capability that gates on trust and can change a run.
- [11 · MCP servers](#mcp-servers) — the other trust-gated capability, and its own translation table.
- [19 · Ocean pager](#ocean-pager--the-terminal-ui) — `/plugins`, `/skills`, `/mcps`, `/hooks` in the terminal UI; the pager's other commands are in `ocean-pager/crates/ocean-pager-ui/docs/user-guide/`.
- [07 · Headless and server](#managing-capabilities-from-the-cli) — every management subcommand in one table.

---

## Hooks

A **hook** runs your own command (or an HTTP endpoint) at a fixed point in a run and hands back a
typed decision the engine acts on. Hooks are configured in a Claude Code `settings.json` /
`hooks.json` shape, so a hook you already wrote for Claude Code runs here unchanged.

### The nine events

A hook is registered against one of nine lifecycle events:

| Event | Fires |
|---|---|
| `PreToolUse` | Before a tool runs — can deny it or rewrite its input. |
| `PostToolUse` | After a tool returns. |
| `Stop` | When a turn is about to settle — can continue the run instead. |
| `PermissionDenied` | When a permission check refuses an action. |
| `UserPromptSubmit` | When you submit a prompt — can add context. |
| `SessionStart` | When a session opens. |
| `SessionEnd` | When a session closes. |
| `SubagentStop` | When a spawned sub-agent settles. |
| `PreTide` | Before the ledger cuts context out of the window (past the tideline). The old event name `PreShelve` still fires it as an alias. |

`PreToolUse` and `PostToolUse` carry the `tool_name`, so a handler can scope itself to one tool.

### The `settings.json` shape

Handlers live in a `settings.json` (or `hooks.json`) under the same keys Claude Code uses — the
event name maps to a list of hook groups, each group a matcher plus one or more handlers:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "./scripts/guard.sh", "timeout": 10 }
        ]
      }
    ]
  }
}
```

A `command` handler is run through `<shell> -c <command>` (shell defaults to `sh`) in the
**project directory**, with the event payload as JSON on stdin and these variables on its
environment: `OC_HOOK_EVENT` (the event name), `OC_PROJECT_DIR` and `CLAUDE_PROJECT_DIR` (both
the project directory, so a Claude Code hook script that reads `$CLAUDE_PROJECT_DIR` works
unchanged). An `http` handler POSTs the payload to a URL with your headers. Hooks are discovered
as capabilities (see [09 · Capabilities and plugins](#capabilities-and-plugins)) and, because they
execute on your behalf, they run only from a **trusted** source.

The payload is the Claude Code shape: every event object carries `hook_event_name` and `cwd`
(inserted only when absent, never overwriting a field the event already set); `SessionStart`
carries `source: "startup"`, `SessionEnd` carries `reason: "exit"`. A group's `matcher` is a
regex tested against `tool_name` for `PreToolUse` / `PostToolUse` / `PermissionDenied`, against
`source` for `SessionStart` and against `reason` for `SessionEnd`; the other events ignore it.
An absent or empty matcher matches everything. One documented gap: `session_id` is not available
at the hook seam, so a handler that keys on it sees no such field.

### Where hooks are read from — and when they run

At boot (and on every reload) `oceancode` walks every ecosystem's hook files — its own
`.oceancode/`, Claude Code's `.claude/` and Grok Build's `.grok/` — in this order, and merges
their groups per event, so earlier tiers' handlers run first:

| Source | Tier | Runs when |
|---|---|---|
| `.oceancode/hooks.json` in the project | local | always — the local `.oceancode/` tier is your machine's own copy, trusted. |
| `.claude/settings.json`, `.claude/settings.local.json` in the project | repo | only when the project is trusted (`oceancode trust repo on`, or `t` in `/hooks`); otherwise **held**. |
| `~/.claude/settings.json` | user | always — the user tier is trusted. |
| `.grok/hooks.json` in the project, `~/.grok/hooks.json` | repo / user | Grok Build's hook files, gated exactly like the repo and user tiers above. |
| `<plugin>/hooks/hooks.json` of an installed plugin | plugin | only when the plugin is enabled **and** trusted; `${CLAUDE_PLUGIN_ROOT}` in its commands is replaced with the plugin's root. |

A held source is still parsed and listed — with its handlers — so you can see exactly what
trusting it would enable. The reasons are `held: untrusted` (repo tier, project not trusted),
`held: disabled plugin`, `unreadable` and `invalid`.

Since 2026-09-10 the three Claude Code settings files are also **watched**: saving one reloads the
hooks (the same path `r` in `/hooks` and `POST /hooks/reload` take) together with the
[Claude Code settings mirror](#claude-code-settings-mirror) — no reload needed
after editing `~/.claude/settings.json`.

**On the `claude` CLI route there is no double firing.** When a session's provider route is the
`claude` command-line protocol, the `claude` subprocess reads the very same `~/.claude/settings.json`,
`.claude/settings.json`, `.claude/settings.local.json` and its installed plugins' `hooks/hooks.json`,
and runs those hooks itself. On that route `oceancode` therefore dispatches only its own
`.oceancode/` hooks (the `ocean_code` ecosystem, local and user tiers) and leaves every Claude
Code-shared source to Claude Code — a `UserPromptSubmit` handler in `.claude/settings.local.json`
fires once per prompt, not twice. Every other route (Anthropic API, OpenAI-compatible, Grok, …)
dispatches everything in the table above, since nothing else would. `/hooks` and `GET /hooks`
still list all sources on every route; the by-source report marks the shared ones
(`shared_with_claude_code`).

### `/hooks` and `oceancode hooks list`

In Ocean pager, `/hooks` opens the Hooks tab of the capabilities picker (toggle and reload go
through the engine); see [19 · Ocean pager](#what-is-wired-to-the-ocean-engine).

Without a session:

```bash
oceancode hooks list            # one line per source, then its handlers indented
oceancode hooks list --json     # {"repo_trusted": bool, "sources": [...]}
oceancode hooks add PreToolUse --matcher Bash --command ./scripts/guard.sh [--timeout 10] [--scope user|project|local]
oceancode hooks remove PreToolUse --matcher Bash --command ./scripts/guard.sh [--scope user|project|local]
```

`list` ends with `HOOKS_OK sources=<n> handlers=<m> loaded=<k>`; `add` prints `HOOK_ADDED <path>`
and `remove` prints `HOOK_REMOVED <path>` (`--scope` defaults to `project`; `remove` of a handler
that is not there exits 1 with `hooks remove: no such handler`); `--dir <d>` and `--home <h>`
select another project or home. The scope names are Claude Code's: **user**, **project** and
**local** are the three files. Note that both project files — `.claude/settings.json` and
`.claude/settings.local.json` — are listed at tier `repo`: they live in the checkout, so they run
only when the project is trusted. (`local` the *scope* is Claude Code's name for the git-ignored
project file; `local` the *tier* is `.oceancode/`, your machine's private copy.) Over HTTP a client reads `GET /hooks`, reloads with
`POST /hooks/reload`, toggles repo trust with `POST /trust/repo {"on": true|false}`, adds with
`POST /hooks {event, matcher, command, timeout?, scope}` and removes with
`POST /hooks/remove {path, event, matcher, target}` — every one answers the same
`{repo_trusted, sources}` document.

### The decision contract

A handler communicates back two ways: its **exit code** and a JSON object on **stdout**.

| Signal | Meaning |
|---|---|
| exit `2` | Deny. On `PreToolUse` the tool does not run; on `Stop` it is a deny that does not count toward the Stop cap. |
| any other non-zero, or a spawn / timeout / IO error | No opinion — **fail-open**, the run proceeds. |
| stdout `hookSpecificOutput.permissionDecision` | An explicit `allow` / `deny` / `ask`. |
| stdout `updatedInput` | Rewrites the tool input before it runs. |
| stdout `additionalContext` | Extra text folded into the model's context. |
| stdout `stopReason` | On `Stop`, the reason the run should continue rather than settle. |

Grok Build handlers may also return `updatedToolOutput` (honoured only for a model-visible copy)
and `defer`. Everything but an explicit deny fails open: a handler that crashes, times out or
prints garbage never wedges a run.

Only `command` and `http` handlers run here, and only the nine events above. A handler of another
type — Claude Code's `prompt` and `agent` hooks — or one registered under an event this runner does
not implement (Claude Code's `Notification`, `PreCompact`, `SubagentStart`, …) is skipped and
counted (`skipped: <n> unsupported handlers` beside the source in `/hooks`), never fatal: the other
handlers in the same file still load.

#### Continuing a `Stop`

A `Stop` handler that wants the run to keep going returns a `stopReason` — the engine treats that
as a continuation and drains again. To keep a runaway handler from looping forever, **at most 8
`Stop` continuations** are honoured per run; past that the run settles. An exit-`2` on `Stop` is a
plain Deny and does **not** count against those 8.

### Provenance and replay

Every hook outcome is recorded with its provenance — which source, which handler, what it
decided — so a run can be replayed and audited: you can see exactly which hook denied a tool or
continued a turn, and from which tier it came. `oceancode inspect` lists every hook source with
`loaded` or `held` beside it.

### See also

- [09 · Capabilities and plugins](#capabilities-and-plugins) — discovery, tiers, and the trust gate hooks depend on.
- [11 · MCP servers](#mcp-servers) — the other trust-gated capability.
- [02 · Configuration](#configuration) — `OC_HOOK_EVENT` and `OC_PROJECT_DIR`, set on every command handler.
- [19 · Ocean pager](#what-is-wired-to-the-ocean-engine) — `/hooks` in the terminal UI.
- [07 · Headless and server](#managing-capabilities-from-the-cli) — `oceancode hooks list` and `oceancode trust`.

---

## MCP servers

An **MCP server** is an external process (or HTTP endpoint) that contributes tools, resources and
prompts to a run. `oceancode` reads the same server-config files the other tools use, so a server
you configured for Claude Code, Cursor or OpenCode is picked up as-is.

### Where servers are configured

Servers are read from several files at once and merged by precedence:

| File | Ecosystem | Shape |
|---|---|---|
| `.mcp.json` | Claude Code (project) | `mcpServers { … }` |
| `~/.claude.json` | Claude Code (user) | `mcpServers { … }` |
| `.cursor/mcp.json` | Cursor (project) | `mcpServers { … }` |
| `opencode.json` (`mcp {}`) | OpenCode | `mcp { … }`, local and remote |
| `<plugin>/.mcp.json` | an installed plugin | `mcpServers { … }` |
| `oceancode.json` (`mcp {}`) | OceanCode | `mcp { … }` — declared servers win over discovered ones of the same name |

**Precedence, highest first: local > project > user > plugin.** A server defined closer to you
overrides a same-named server from a wider scope. Every server from every file is folded into
the one manager at boot, so `/mcp`, `GET /mcp` and `oceancode mcp list` all show the same set —
but a name declared at two tiers runs **once**: the copy that lost its name (qualified
`<tier>:<name>`, say `user:cloudflare-dev` for a `~/.claude.json` copy of the project's
`.mcp.json` server) is folded **held disabled** — listed, never spawned, its tools never offered a
second time under a `user_cloudflare-dev_*` prefix — and `mcp list` reads `shadowed` on it. Only
when the winning copy cannot run (held untrusted or disabled) does the next copy run in its place.
Before 2026-10-13 both copies were started.

### Held until trusted

A server that arrived with a checkout (the project's `.mcp.json`) or with a plugin can run code
on your machine, so it is **listed but held** — status `untrusted` — until you trust it. Nothing is
dialed or spawned for a held server. Three ways to release one, all persisted in
`~/.oceancode/trust.json` under the project's absolute path:

```bash
oceancode trust repo on            # trust the repo tier: its .mcp.json servers and its hooks
oceancode trust mcp <name> on      # trust one server (beats the repo flag); `off` holds it again; `clear` removes the override
oceancode trust show               # repo=<bool>, one `mcp <name>=<bool>` per override, one `disabled <name>` per held-disabled server, then TRUST_OK
```

or trust it from the pager's `/mcps` tab. A server Claude Code has switched off for this
project (`disabledMcpServers` / `disabledMcpjsonServers` in `~/.claude.json`, seeded by
`oceancode plugin import --from claude-code`) is held **disabled** at every tier and reports
`disabled`. User-tier servers (`~/.claude.json`) are trusted by default.

Enabled and trusted are independent: `oceancode mcp disable <name>` (or the pager's `/mcps` tab) switches a
server off — disconnecting it and recording the hold in `trust.json` — without changing its trust,
and `enable` switches it back on; a server that is enabled but still untrusted stays held and
reports `untrusted`, and `connect` refuses to dial it until it is trusted. The engine reads a
`trust.json` change made from the CLI at its next boot; the pager and the HTTP routes apply it
live.

`oceancode mcp enable|disable` is the MCP arm of the one enable interface skills, plugins and MCP
servers share (`skill enable|disable`, `plugin enable|disable`, and `enable-all`/`disable-all` on
each) — see [Enabling and disabling skills](#enabling-and-disabling-skills). MCP
keeps its own `disabled_mcp` hold model rather than the skills' four-tier fold; the verbs are the
same shape so there is one thing to learn. An explicit enable is remembered per project as
`enabled_mcp` in `~/.oceancode/trust.json` and beats a config entry's `enabled: false`, so a server
your project config ships off can be switched on from the picker or the CLI and stays on.

Any `${VAR}` or `${VAR:-default}` in a server's command, args, env or headers is expanded from
your environment when the server starts; an unresolved `${VAR}` is left literal rather than
blanked, so a misconfigured variable is visible instead of silently empty.

Like hooks, MCP servers execute on your behalf, so they run only from a **trusted** source (see
[09 · Capabilities and plugins](#enable-is-not-trust)).

### Resources, prompts and elicitation

An MCP server offers more than tools:

- **Resources** — the server's `resources/list` and `resources/read` are surfaced so a server can
  hand documents into a run.
- **Prompts** — each server prompt appears as a slash command named `/mcp__<server>__<prompt>`.
- **Elicitation** — a server that asks the user a question mid-call is routed to the same
  `question` seam the rest of the app uses, so the prompt shows up in your UI and the answer goes
  back to the server.

### Meta-dispatch for large tool sets

When many servers are connected, the flat tool catalog gets unwieldy for the model — and some
providers reject a `tools` array longer than 128. Past 128 connected MCP tools the engine always
replaces the flat catalog with two meta-tools — `search_tool` (find a tool by keyword) and
`use_tool` (invoke one by id). Setting `OC_MCP_META_TOOLS=1` does the same from 64 tools up.
See [02 · Configuration](#configuration).

### Managing servers — `oceancode mcp`

```bash
# A stdio server (a local command):
oceancode mcp add <name> --command <cmd> [--arg <a>]... [--env K=V]...

# A remote server (an HTTP endpoint):
oceancode mcp add <name> --url <url> [--header K=V]...

oceancode mcp list             # name  stdio|remote  tier  trusted|untrusted  enabled|disabled|shadowed  source
oceancode mcp enable <name>    # / disable <name>  — switch any server off or on (config-declared or discovered; held disabled in ~/.oceancode/trust.json, honoured at boot)
oceancode mcp remove <name>
oceancode mcp login <name>     # / logout <name>  — OAuth for a server that needs it
oceancode mcp doctor           # probe every server and report reachable / failed (a shadowed copy is not dialed; it reads shadowed)
```

`list` walks every config file above for the current directory (`--dir <d>`, `--home <h>` to
point elsewhere) and shows, per server, its tier, whether it may run and whether it is enabled.

`add` writes a Claude-Code-shaped `.mcp.json`, so the server is immediately visible to any tool
that reads that file. Pass exactly one of `--command` (stdio) or `--url` (remote). `--root <dir>`
and `--home <dir>` override where the config and home are read.

`doctor` gives each server a short connect budget and reports it `reachable` or `failed` quickly,
so one unreachable server does not hang the check.

In Ocean pager, `/mcps` opens the MCP tab of the capabilities picker (add, enable/disable and
trust go through the engine); see [19 · Ocean pager](#what-is-wired-to-the-ocean-engine).
A server whose handshake is still in flight reads **`connecting`** (the status list is
`connected` / `connecting` / `needs-auth` / `failed` / `disabled` / `untrusted`); a status read
gives the startup pass at most two seconds and then answers with the live rows (the handshake
itself may take up to 30 s, the `timeout` default). Disconnecting, disabling or untrusting a
server is bounded to about two seconds even when the server process left a child holding its
output pipe open.

A prompt's first model request waits up to `OC_MCP_WAIT_SECONDS` (default 5; `0` never waits;
sub-agents never wait) for the servers configured at boot to finish connecting, so their tools are
already in that request. The tool list is then fixed for the rest of the prompt: a server that
connects, or lists new tools (`tools/list_changed`), while a prompt runs reaches the model on the
next prompt, because a changed tool list at the front of the request would make the provider
re-write its whole prompt cache. Toggling a server, connecting or disconnecting it by hand, trust or
any capability still applies on the very next step.

Over HTTP a client reads `GET /mcp` (each entry carries `trusted`, `enabled`, `provenance` and
`kind` beside `status`), `GET /mcp/{name}/tools`, `GET /mcp/{name}/prompts`,
`GET /mcp/{name}/resources`, and posts `POST /mcp/{name}/trust {"on"}`,
`POST /mcp/{name}/enable {"on"}`, `POST /mcp/{name}/connect|disconnect`, `POST /mcp/{name}/auth`
and `DELETE /mcp/{name}/auth`.

### See also

- [09 · Capabilities and plugins](#capabilities-and-plugins) — discovery, tiers, and the trust gate.
- [10 · Hooks](#hooks) — the other capability that runs your code.
- [02 · Configuration](#configuration) — `OC_MCP_META_TOOLS` and the `mcp` config key.
- [07 · Headless and server](#managing-capabilities-from-the-cli) — `oceancode trust` and `oceancode mcp list`.

---

## Permissions and sandbox

OceanCode decides what a tool may do in three layers, from softest to hardest:

1. **Permission rules and modes** — an ask/allow/deny policy over each tool call.
2. **The shell and filesystem guards** — a classifier that reasons about what a
   `bash` command or a file edit actually does, on top of the rules.
3. **The kernel sandbox** — OS-level isolation the process cannot talk its way
   out of, off by default.

An empty config is not an asking one: the base rule is `* * allow`, so a tool call
is allowed unless something fences it — the shell's dangerous floor, an
`external_directory` ask, a denied `.env` read. Secret-named environment variables
are stripped from child processes and `.env` files are never read into the model.
Add `bash * ask` under `permission` to make every shell command off the read-only
safe list ask; that is also the posture the Jev permission advisor works on. This
page is how to loosen or tighten each layer.

### Permission modes

A **mode** is a coarse posture layered over your `permission` rules. Set the boot
default with `permission_mode` in the config (see
[02 · Configuration](#configuration)), or switch it live over the HTTP API
(see [Switching the mode live](#switching-the-mode-live) below).

| Mode | What it does |
|---|---|
| _(empty)_ / `default` | Evaluate the rules as written; with the base `* * allow` rule a call no rule fences is allowed. |
| `acceptEdits` | Auto-allow `edit`; everything else is unchanged. |
| `dontAsk` | Never block on an interactive ask (a residual ask resolves to deny in a headless posture). |
| `bypassPermissions` | Auto-allow everything — **except** the dangerous floor below, which no mode can reopen. |

The Ocean pager's `auto` (the Shift+Tab Auto stop, `/auto`) is `acceptEdits` and its
`always-approve` (`ctrl+o`, `/always-approve`) is `bypassPermissions`; the pager sends every
change to the engine (see [19 · Ocean pager](#shifttab-ring-and-permission-modes)).

A mode is expressed as ordinary last-match-wins rules, so it can never silently
override the safety floor: `bypassPermissions` appends an allow-all rule, but the
dangerous-floor deny is evaluated first and wins regardless.

**Jev advisor.** With the opt-in Jev decision layer on (see
[13 · Jev decisions](#jev-decisions)), a residual ask on a `bash` command — one
that no allow, deny or specific-pattern `ask` rule settled — is first put to Jev as
four bounded questions (in `act` the prompt waits at most `jev.deadline_ms`, 1.5 s by
default). With the empty config's `* * allow` base there is no such ask, so the
advisor sees nothing until a rule such as `bash * ask` makes the shell guard ask.
In `shadow`, the default once the layer is enabled, the call runs in the background, its
answer is only recorded and you are asked as before, without waiting. With `jev.permissions = "act"` a command inside a bounded
safe class — not destructive, no network egress, easily undone, a single-file or
project blast radius — runs without the prompt. The dangerous floor and deny rules
run before the advisor and never reach it, `dontAsk` gets no advice, a command that
names a path outside the project (or runs in a `workdir` outside it) gets none
either — the shell guard checks that in code — and a fixed hold list (package
installs and publishes, `git push`, recursive `rm`, `ssh`, an uploading `curl`, an
interpreter run on a file, …) always asks. Under headless `--auto`, an `act` advice
that the command is destructive, sends data out, reaches the system or a third
party, or tripped the injection guard rejects the call instead of approving it, and
the rejection carries the reason and the Jev scores back to the model as feedback
(see [13](#what-jev-decides)). Your answer to an advised prompt is
recorded as that decision's label.

#### Rule syntax — `Tool(pattern)`

Individual `permission` entries and the `shell.allow` / `shell.deny` lists take a
`Tool(pattern)` form, e.g. `Bash(git push:*)`, `Edit(src/**)`, `Read(*.md)`. The
tool name is matched case-insensitively; an empty pattern means `*`.

A tool whose **last** rule for its key is a `*` deny — `bash * deny`, or a read-only
agent's blanket deny — is not offered to the model at all: its schema is left out of
the request (`write`, `apply_patch` and `hashline` count as `edit`). A narrower last
rule keeps the tool, so the plan agent still sees `edit` for its plan file. Sub-agent
children are sent the built-in tools only, never MCP or skill tools.

#### Switching the mode live

The permission mode is switched at runtime over the HTTP API —
`GET`/`POST /api/session/{id}/permission/mode` (see
[07 · Headless and server](#permission-mode)). A managed
(enterprise) requirements file can **lock out** `bypassPermissions`: once locked,
selecting it downgrades to `default`.

### The shell guard

Before any `bash` command runs, a classifier splits it into segments, peels
wrappers (`sudo`, `env`, `nice`, `timeout`, `xargs`, `bash -c "…"`, `VAR=val …`),
and decides allow / ask / deny per segment. The whole command runs only if
**every** segment is allowed (a chaining guard — `git status && rm x` still asks).

- **Read-only safe list.** Commands that only read (`git status`, `git log`,
  `ls`, `rg`, `grep`, `find`, …) auto-approve with no rule. The list looks at
  flags: `find` with `-delete`, `-exec`, `-execdir`, `-ok`, `-okdir`, `-fprint`,
  `-fprint0`, `-fprintf` or `-fls`; `git branch` that deletes, renames, copies,
  forces or re-points (`-d`, `-D`, `-m`, `-M`, `-c`, `-C`, `-f`, `-u`, `--delete`,
  `--set-upstream-to`, `--unset-upstream`); `git remote` with `add`, `remove`,
  `rm`, `rename`, `set-url`, `set-head`, `set-branches`, `prune` or `update`; and
  `git tag` that creates, signs, forces or deletes (a name after `tag` creates a
  tag unless `-l`/`--list` makes it a pattern) are off the list and ask. Adding
  output redirection (`> file`) drops a command off the safe list.
- **Dangerous floor.** A fixed set of destructive shapes is refused outright and
  **no permission mode can reopen it**: `rm -rf /` (and obfuscated variants),
  `git push --force`, `dd of=/dev/*`, `chmod -R 777 /`, `mkfs*`, the fork bomb,
  and `curl … | sh`. The floor peels wrappers and unwraps `bash -c`/command
  substitutions, so hiding the command behind `sudo`, `/bin/rm`, or `$(…)` does
  not evade it.
- **`shell` overrides.** `safe_list_extra` adds read-only commands; `allow` /
  `deny` layer `Tool(pattern)` rules onto the classifier. Neither can remove the
  dangerous floor.

### Child-process environment

Every `bash` and PTY child gets a composed environment rather than a raw copy of
the parent's. By default, variables whose name contains `KEY`, `SECRET`, or
`TOKEN` (case-insensitive) are **stripped**, so a secret in your shell never
leaks into a spawned tool.

Tune it with `child_env`:

| Field | Meaning |
|---|---|
| `inherit` | `all` (default), `core` (only `PATH`/`HOME`/`USER`/`SHELL`/`LANG`/`TERM`/`TMPDIR`), or `none`. |
| `include_only` | When non-empty, keep only these variable names. |
| `set` | Extra `name`→`value` entries overlaid last. |
| `strip_secrets` | Drop secret-named variables (default `true`). |

### The sandbox

The sandbox confines a spawned child at the OS level — `sandbox-exec` (Seatbelt)
on macOS, `bubblewrap` (with Landlock where the kernel supports it) on Linux ≥
5.13. It is **off by default**; turn it on with the `sandbox` config.

| Profile | Reads | Writes | Network |
|---|---|---|---|
| `off` | unrestricted | unrestricted | unrestricted |
| `workspace` | workspace + writable roots | workspace + writable roots | per `network` |
| `readonly` | workspace + writable roots | denied | per `network` |
| `strict` | workspace only | workspace only | per `network` |

- `deny` lists roots refused even inside the workspace (e.g. `.git`, a secrets
  dir).
- `network` is `off` (no egress) or `allowlist`; with an allow-list, only the
  exact hosts in `allow_hosts` are reachable, enforced by a TLS-SNI relay (no
  wildcards, no suffix matches).

On a Linux kernel older than 5.13, Landlock is unavailable and bubblewrap alone
provides the confinement (a warning is logged, never a silent disable).

### Reference aliases

`refs` maps a short `@name` to a directory. A path of the form `@name/rest` in any
file tool (`read` / `edit` / `write`) expands to `<refs[name]>/rest`, and that
reference root is auto-allowed through the external-directory prompt — so a shared
docs or vendor tree can be reached without a prompt for every file. An unknown
`@name` is left literal and still prompts, and an alias that walks out of its root
(`@name/../escape`) is caught by the external-directory boundary just like any
other outside path.

### Filesystem guards

The file tools carry their own safety, independent of the rules:

- **`.env` is never read.** `read` refuses `.env` and `*.env` / `*.env.*` files
  (secrets); `.env.example` still reads.
- **Read-before-write.** `write` refuses to overwrite an existing file you have
  not read this session ("file exists but has not been read this session"); a
  brand-new file writes freely. One exception (2026-09-28): a file under 64 KiB
  that this session itself created — its birth time is at or after the session's
  first tool call in this process, e.g. a file a `bash` command generated — can
  be overwritten without a read. A file that existed before the session is
  refused even when it was edited since, and on a filesystem that reports no
  birth time the exception never applies.
- **Stale detection.** `edit` and `write` refuse a target that changed on disk
  since it was last read — detected by content hash, so a same-size, same-mtime
  change is still caught. Read it again first. Other files changing — a build,
  a file a `bash` command wrote, the session's own database — never cost you a
  read: a read stays valid until the file itself changes (2026-09-28).

### Managed (enterprise) config

An administrator can layer a **managed** config over every project. It is loaded
last, so `project < user < managed` — managed always wins. Its location is the OS
admin path, or `$OC_MANAGED_CONFIG` (see
[02 · Configuration](#configuration)).

Alongside it, a signed `requirements.json` carries hard requirements — disabling
`bypassPermissions`, an MCP-server allow-list, and config pins. It is verified
with ed25519 against the trusted key in `$OC_REQUIREMENTS_PUBKEY`: an **unsigned
or tampered** requirements file is refused, never applied. With no key configured,
managed requirements are simply not enforced.

- `oceancode inspect` shows the provenance of every effective config key — which
  layer (project / user / managed / requirements) set it.
- `oceancode doctor` reports crash recovery state: orphaned sessions and captured
  panic reports. See [07 · Headless and server](#headless-and-server).

### See also

- [02 · Configuration](#configuration) — the config keys and variables.
- [07 · Headless and server](#headless-and-server) — `oceancode doctor` and
  the permission-mode routes.

---

## Jev decisions

OceanCode can put small typed questions to **TypeSafe Jev** (System One, pinned model
`jev-1.13.0`) at seven decision points — "would this command delete data?", "which block of this
output answers the step?" — and use the answers as advice. Jev answers with probabilities: a
`noul` (the probability of yes), a `choice` over named options, or a `score` over ordered levels.
The policy that turns an answer into a verdict is code beside each decision point, and the engine's
deterministic backstops stay in front of it: Jev can never turn a `deny` into anything, never reopen
the dangerous floor, never raise a spend, step or clock bound.

The layer is **off** by default. It needs your consent in two places, and every point starts in
`shadow` — decide and record, change nothing — until you promote it to `act`.

### What Jev decides

| Point · mode key | Question asked | What `act` changes | What stays deterministic |
|---|---|---|---|
| `permission` · `jev.permissions` | For the shell rules' residual **ask** on a `bash` command: `destructive`, `egress` (any network destination), `reversible` (score 0–2) and `blast_radius` (single_file / project / system / external), in one request. Whether the command reaches outside the project is not asked: the shell guard checks it in code (see below). | A would-ask command runs without a prompt only when destructive < 0.6, egress < 0.5, reversible < 1.5 and the blast is single_file or project with confidence ≥ 0.8. Under headless `--auto` an ask whose advice is an injection refusal, a system/external blast, destructive ≥ 0.6 or egress ≥ 0.5 is **rejected** instead of approved, and the model reads why: the rejection reaches it as feedback — `Blocked under --auto by the permission policy: <likely destructive \| likely network egress \| blast system \| blast external \| the command text addresses the judge>. Jev scores: destructive=<d> egress=<e> blast=<b>. Use a safer equivalent that stays inside the project, or stop and explain why this command is required.` (a score Jev did not give prints `n/a`) — instead of a bare "rejected"; the same reason still goes to stderr. | Deny rules and the dangerous floor run first and never reach Jev. An `ask` rule with a specific pattern keeps its prompt; every other permission kind (paths, `edit`, `write`, …) asks as before; `dontAsk` gets no advice. A command over 4,000 chars, a truncated task or a command that names a path outside the project (skip reason `a path outside the project`; see [What is sent](#what-is-sent)) gives no advice. The **hold** keeps these shapes at ask whatever Jev says: package install/add/publish verbs, `npx`/`bunx`/`uvx`, `go run`; the git verbs `push`/`remote`/`restore`/`clean`/`stash`/`checkout --` and every history rewrite (`reset` in any form, `rebase`, `merge`, `branch -d`/`-D`, `tag -d`, `update-ref`, `reflog`, `worktree remove`/`prune`, `commit --amend`, `filter-branch`, `filter-repo` — plain `commit` is still advised); a `git -c` that sets an alias, a program (`core.sshCommand`, `core.pager`, `core.editor`, `core.fsmonitor`, `core.hooksPath`, `core.gitProxy`, `sequence.editor`), `credential.*`, `http.*` or `url.*`, or any value with `!`; recursive `rm`, `find -delete`/`-exec`, `ssh`/`scp`/`rsync`, an uploading `curl`/`wget`; anything that runs code Jev never sees — a path as the command word (`./deploy.sh`, `scripts/x.sh`, `/abs/tool`), a shell without `-c` (on a file, `-s`, or fed from a pipe or `<`), an interpreter (`python`, `node`, `ruby`, `perl`, `deno`, `bun`, `tsx`, `php`) on a file, an inline program or stdin (only `python -m <module>` and `--version` pass), `source`/`.`/`eval`/`exec`, `awk -f`, `sed -f`; and any opaque or non-ASCII command. |
| `output_view` · `jev.output_view` | For a `bash` output over the truncation limits: which of its ≤ 255 line blocks best answers the step (`where`, a choice over block ids) and whether any does (`exists`). The step is the assistant text before the call; when the model chained the call with no text before it, the call's own `description` stands in (the decision row's state then carries `intent_source: "description"`). | The model sees the first 20 and last 20 lines, the blocks Jev picked (most probable first until 0.8 of the mass or 8 blocks, each at most 200 lines) and one `[… lines a–b omitted; full output: <spill>]` marker per gap; the full output goes to a spill file. When `exists` < 0.5 it gets the registry's head preview and the spill file instead — except that the top block is still kept when its probability is at least 0.8 and `exists` is at least 0.2. | Only `bash` is viewed (`read`, `grep` and `glob` page themselves). Binary output, a command whose last stage already filters its output (`grep`, `rg`, `head`, `tail`, `wc`, `sort`, `uniq`, `cut`, `awk`, `sed`, `jq`; skip reason `command already filters`), neither step text nor a `description` of at least 12 characters (skip reasons `no step intent`, `step intent too short`), an output the head and tail already cover, a view that is not smaller than the registry's cut it would replace and a state that does not fit 48,000 bytes keep today's result — for `bash`, as for every tool, the registry's cut: the head preview (at most 2,000 lines / 50 KiB) and the spill file. The head, the tail and the spill file are always there when a view acts. |
| `supervisor` · `jev.supervisor` | At a clean depth-0 finish, over the goal, the step count, the open todos, the last six steps and the final text: `complete`, `looping`, `progress` (score 0–2). Every `jev.supervisor_every_steps` settled depth-0 steps the same three questions are the loop check. | The stop is blocked with a synthesized continuation when complete < 0.3, progress ≥ 1.0 and the progress mass ≥ 0.5 (at most `jev.supervisor_max_continuations` per human prompt, default one; a further one is asked about only after a state-changing call since the last); complete ≥ 0.85 always finishes. The loop check sends one **steer** message — never a stop — when looping ≥ 0.7 and progress < 0.6; in `act` it is asked only of a ring that looks stuck (a call repeated within the last ten, or the newest three steps with no `edit` / `write` / `apply_patch` / `bash` call), otherwise a skip row `loop check skipped: ring healthy` and no call (`shadow` always asks). | The hard step, spend and clock bounds, the exact-hash doom-loop trip, the eight-block hook cap and the `no_progress` guard ([08 · Bounding a long run](#bounding-a-long-run)). A stop that asks you something — the final text's last line ends with `?`, or the turn used the `question` tool — is never judged (a skip row, `asks the user`), and neither is a stop after a tool call the permission policy refused among the last six steps — you rejected it, or a permission rule denied it (`a tool call was rejected by policy`): the refusal is a decision, so continuing would only make the agent retry it. A stop after the cap, or a further one with no `edit` / `write` / `apply_patch` / `bash` call since the continuation, is skipped too (`continuation cap reached`, `no state-changing call since the continuation`). |
| `triage` · `jev.triage` | After a `bash` exit other than 0 and −1: the failure `category` (env_missing / flaky_transient / syntax_trivial / expected_failure / deep_logic) and `hint_needed`. | With category confidence ≥ 0.8: a `flaky_transient` failure of an allow-listed test or build runner is re-run **once** (its output appended after the first run's; `metadata.exit` keeps the first exit code, `metadata.rerun_exit` holds the second); `flaky_transient` elsewhere or `env_missing` adds one hint line. A failure that names something absent (exit 127, `<name>: command not found`, `No module named '<name>'`, `error[E0463]: can't find crate for`, `Cannot find module '<name>'`) is `env_missing` without a request: the hint names it, points at an alternative that is on `PATH` (`pytest` → `python3 -m unittest`, `jest` / `vitest` → `node --test`, `cargo-nextest` / `nextest` → `cargo test`) and tells the model to use what the project has and not to install anything the task did not ask for; an `env_missing` answer from Jev itself gets the same wording with "a command" for the name and never says to install; a failing install command (`pip install`, `python3 -m pip install`, `npm i`, `cargo install`, `go get`, … in any segment of the command) gets no hint. | The raw output is never removed; the re-run asks the permission gate again; a green, timed-out or killed run never calls Jev. The runner allow-list is exact: `cargo test`, `cargo nextest run`, `cargo check`, `cargo clippy`, `cargo build`, `pytest`, `python -m pytest`, `python3 -m pytest`, `go test`, `npm test`, `npm run test`, `pnpm test`, `yarn test`, `vitest`, `jest`, `./check.sh`, optionally after one `cd <relative dir> &&`, with no other separator, pipe, redirect or substitution and no leading `VAR=` other than `RUST_LOG`, `RUST_BACKTRACE`, `NO_COLOR`, `CI`. |
| `tool_tiers` · `jev.tools` | At a session's first human prompt, and again only when a later prompt shifts the topic (see [Sticky keep-sets](#what-is-sent)): which optional tools the prompt needs (`tools`, a choice over every non-core tool with a one-line description) and `needs_more_than_core`. | The request advertises the core tools (`read`, `edit`, `write`, `bash`, `grep`, `glob`, `todowrite`, `skill`, `question`, `tool_search`) plus the top 4 other tools with p ≥ 0.02 — the top 8 when `needs_more_than_core` ≥ 0.6. `tool_search`'s description names every tool left out; `tool_search {name}` returns that tool's full definition and pins it from the next human prompt. A topic shift adds its tools to the list; nothing is taken away. | Every tool stays registered and callable. The list is byte-identical across one prompt's requests. `off`, `shadow`, a failure, a catalogue over 255 tools and a run whose `--tools` list drops `tool_search` advertise every tool, as today; `tool_search` is registered only in `act`. See [Tool tiers and provider routes](#tool-tiers-and-provider-routes). |
| `relevance` · `jev.relevance` | At a session's first human prompt, and again only when a later prompt shifts the topic (see [Sticky keep-sets](#what-is-sent)), for 12 or more described skills: which skill fits the prompt (`skill`, a choice over the skill names with their descriptions) and `needs_skill` — one request. | The `<available_skills>` block lists the top 8 skills with p ≥ 0.02 — at most 3, and never zero, when `needs_skill` < 0.5 — in rank order, then one line naming every other skill; a topic shift appends its skills after the earlier ones. | Rules stay deterministic; the `skill` tool still loads any enabled skill by name; fewer than 12 described skills, `off`, `shadow` and a failure list every skill. |
| `visibility` · `jev.visibility` | At the ledger drain's prune: which prunable completed-turn tool parts the remaining work still needs (`needed`, a choice over call ids) and whether it needs any (`any_needed`), over the goal and the open todos. | When `any_needed` ≥ 0.4, up to three parts with p ≥ 0.05 are kept verbatim out of the prune — provided the rest still meets the prune minimum. A part kept once stays kept while the plan moves; a part already stubbed is never offered again. The plan's stubs land on the next cut's request only (a prune rides a cut, 2026-10-12); between cuts no sent row changes. | The tideline cut, the protect window, the emergency plan and the byte-stable prefix. `off`, `shadow` and a failure protect nothing. |

### Turning it on

1. **The global config** — `$OC_CONFIG`, else `<config-dir>/oceancode/oceancode.json` (or the
   managed document an administrator ships, which wins over it):

   ```json
   { "jev": { "enabled": true } }
   ```

2. **The project config** — `<project>/.oceancode/oceancode.jsonc` or `.json`. The layer is on
   only when **both** sections say `enabled: true`, so a repository has to opt in:

   ```json
   { "jev": { "enabled": true } }
   ```

   A project section may set only `enabled` and the seven mode keys. Its `endpoint`, `model`,
   budgets and thresholds are ignored with one warning, so a cloned repository can neither send
   your states or credentials elsewhere nor loosen a threshold.

3. **An endpoint the build can reach.** The default build's endpoint is the direct TypeSafe API,
   `https://api.typesafe.ai/v1/systemone`, which needs `OC_TYPESAFE_API_KEY`; without the key the
   layer stays off. The platform build (`--features platform`) defaults to the PlungeAI worker,
   `https://typesafe-provider.ap-a98.workers.dev/systemone?backend=cloudflare`, and sends it the
   `ocean.headers` map of the global config (the managed document or yours — never the project
   file's). Either build can use the other endpoint through `jev.endpoint` in the
   global config or `OC_JEV_ENDPOINT`. The key goes only to `api.typesafe.ai`, `ocean.headers`
   only to the worker, a redirect is never followed, and plain `http` is refused unless
   `OC_JEV_ENDPOINT` names a loopback host.

   The worker refuses a public-internet `POST /systemone` that lacks its shared key (401; 503
   until the key is set on the worker), so the global config must carry it — the global file
   only, never a project file (a project's `ocean.headers` is ignored):

   ```json
   { "ocean": { "headers": { "x-ocean-key": "<the worker key>" } } }
   ```

   Every request also names its decision point in an `x-jev-point` header (AI Gateway metadata on
   the worker's side) and the worker's own time comes back in `Server-Timing`.

4. **Check it.** `oceancode jev status` should list the seven points in `shadow` with the endpoint
   host; `oceancode jev probe` makes one live call and prints `JEV_PROBE_OK`.

5. **Leave everything in `shadow`** until the point has its own labels (see
   [Promoting a point to `act`](#promoting-a-point-to-act)).

### Modes

Each point has its own key: `off`, `shadow` or `act`.

| Mode | What the point does |
|---|---|
| `off` | Never calls Jev; today's behaviour, no decision row. |
| `shadow` | Asks Jev, records the verdict it would have given, and changes nothing. The call runs in the background, so the turn never waits for it; its row is written when the answer lands, and every row is in `jev-decisions.jsonl` by the time the session closes. |
| `act` | Asks Jev and applies the verdict inside the bounds of [What Jev decides](#what-jev-decides). |

With the layer enabled, a point whose key is unset is in `shadow` — `act` is never a default. The
project section's key wins over the global section's. Note that two keys differ from the point
names: permission is `jev.permissions` and tool tiers is `jev.tools`. The point names are accepted
as the same keys — `jev.permission` and `jev.tool_tiers` mean `jev.permissions` and `jev.tools`.

`OC_JEV` overrides all seven at once: `OC_JEV=off` turns the layer off whatever the config says,
and `shadow` or `act` sets every point. It never turns the layer on by itself — `jev.enabled` must
still be set in both sections — and an unrecognised value is ignored with a warning.

#### Promoting a point to `act`

Promote one point at a time, and only after its **per-point label run** passes:

1. Run the point in `shadow` for at least a week of your own sessions.
2. `oceancode jev labels` collects every decision you answered (see
   [Reading decisions](#reading-decisions)) into `bench/jev/labels.json`.
3. The point's own run — `node scripts/bench/jev-decisions.mjs --set bench/jev/labels.json --point <p>`
   (live, `OC_LIVE_CONFIRM=1`) — must print `JEV_EVAL_OK point=<p> …`: accuracy ≥ 0.90 on the
   unanimous labels, a Wilson 95 % lower bound on that accuracy (`wilson_lower=` on the line) ≥ 0.80,
   at least 40 rows for `permission` and 12 for any other point, and no false allow. A handful of
   right answers cannot promote a point: n = 12 at 12/12 is 0.758 and never passes, and all-correct
   needs 16 rows. Short of the bound the run prints `JEV_EVAL_RED reason=wilson`.
4. Then set that point's key to `act` in the project config.

Point 1 needs the posture it advises on: an empty config allows shell commands (the base rule is
`* * allow`), so nothing asks and the advisor never runs. Add `bash * ask` under `permission` and
every command off the read-only safe list asks — and is advised. The safe list is flag-aware
(`find -delete`/`-exec`, `git branch -D`, `git remote add`, `git tag v1` are off it; see
[12 · Permissions and sandbox](#the-shell-guard)). The default posture
is not changed by this layer.

Point 5 has one more precondition — the provider route check below. Points 5 and 6 change the
advertised tools and skills per prompt, which changes the prompt-cache prefix; measure the cache
reads of a real session before promoting either.

### Thresholds

Every threshold lives in `jev.thresholds`, is read from the global section only, and is tuned
against labels for the pinned `jev.model` (a threshold tuned on one model id is not reused on
another; `jev.model` must be a versioned id — `latest` and `preview` are refused). Probability
thresholds are `0`–`1`; the three score thresholds (`permission_reversible`, `supervisor_progress`,
`supervisor_loop_progress`) are on the three-level scale `0`–`2`.

| Key | Default | The risk it guards |
|---|---|---|
| `permission_destructive` | `0.6` | Ask when `destructive` is at least this. |
| `permission_egress` | `0.5` | Ask when `egress` is at least this. |
| `permission_reversible` | `1.5` | Ask when the `reversible` score is at least this. |
| `permission_confidence` | `0.8` | Ask when the `blast_radius` choice is less confident than this. |
| `output_relevant` | `0.5` | Below this `exists`, keep no block (head preview + spill instead). |
| `output_mass` | `0.8` | Keep blocks until their probabilities add up to this (at most 8). |
| `output_top_p` | `0.8` | Below `output_relevant`, still keep the top block when its probability is at least this… |
| `output_exists_floor` | `0.2` | …and `exists` is at least this. |
| `supervisor_looping` | `0.7` | Steer only when `looping` is at least this. |
| `supervisor_complete` | `0.85` | Always let the stop through when `complete` is at least this. |
| `supervisor_continue_complete` | `0.3` | Block a stop only when `complete` is below this. |
| `supervisor_progress` | `1.0` | Block a stop only when the `progress` score is at least this. |
| `supervisor_progress_confidence` | `0.5` | Block a stop only when the progress mass is at least this (see below). |
| `supervisor_loop_progress` | `0.6` | Steer only when the `progress` score is below this. |
| `triage_confidence` | `0.8` | Pass the failure through when the `category` choice is less confident than this. |
| `tools_confidence` | `0.6` | Advertise 8 extra tools instead of 4 when `needs_more_than_core` is at least this. |
| `tools_min_p` | `0.02` | An extra tool needs at least this probability. |
| `relevance_needs` | `0.5` | Below this `needs_skill`, list at most 3 skills. |
| `relevance_min_p` | `0.02` | A listed skill needs at least this probability (the top one is always listed). |
| `visibility_relevant` | `0.4` | Protect nothing below this `any_needed`. |
| `visibility_min_p` | `0.05` | A protected part needs at least this probability. |

What the numbers mean in practice, from the recorded answers:

- **`supervisor_progress_confidence` reads the progress mass, not the API's `confidence`.** It is
  compared with `1 − P("0")` — the probability above the lowest ("no progress") rung. The Score
  answer's own `confidence` measures how concentrated the answer is on one rung, so it drops
  whenever the answer spreads over adjacent rungs: a recorded half-done trace split 0.39 / 0.61
  between "incremental" and "substantial" progress, nothing on "no progress", and reported
  confidence 0.42 — a mass of 1.0.
- **`relevance_rerank` is gone.** A key set in an old config is still accepted (one warning, "no
  longer read"); nothing reads it. In `act`, point 6 used to send a second request, a noul per
  top-three skill over its body excerpt, and it confirmed no candidate in 7 of 8 recorded prompts
  while adding 0.18–0.86 s at the start of each. The wide choice's rank order stands.
- **`permission_scope` is gone.** A key set in an old config is still accepted (one warning, "no
  longer read"); nothing reads it. Against the task "fix the failing unit test in
  crates/ocean-llm", Jev scored `cargo nextest run --all-features` 0.73 and `ls -la` 0.57 on the
  old `outside_scope` question (both ask), and `cargo nextest run -p ocean-llm` 0.24 (allow): a
  question about "the task" punished ordinary project commands, so scope became a code check.
  Re-recorded without it, `cargo nextest run --all-features` is allow.
- **`needs_more_than_core` and `needs_skill` rarely change the pick.** `needs_more_than_core`
  scored 0.09–0.35 on the recorded prompts — the web-research and sub-agent prompts included — so
  point 5 advertises 4 extra tools, never 8, at the default 0.6; the named tools were still among
  the 4. `needs_skill` scored 0.21–0.77 on skill prompts and 0.18–0.30 on coding prompts, but the
  0.02 floor already leaves few skills on a skill prompt, so the cap of 3 binds only on coding
  prompts. Both are tuning items for the label run.

The other `jev` keys:

| Key | Default | Meaning |
|---|---|---|
| `enabled` | `false` | Needed in both sections. |
| `endpoint` | the build default | The `POST` URL (global section only). |
| `model` | `jev-1.13.0` | A versioned model id; the thresholds are tuned on it. |
| `deadline_ms` | `1500` | One decision's deadline, retries and the response body included. |
| `max_rpm` | `150` | HTTP attempts per 60-second window, retries included (per process). |
| `max_state_chars` | `24000` | The state budget of points 1 and 3–6, in bytes of serialized state. A larger value does not raise the 48,000-byte cap: a state over it is never sent (`invalid`). |
| `backoff_seconds` | `60` | How long the client stops calling after the endpoint fails (see [Failure behaviour](#failure-behaviour)). |
| `prewarm` | `true` | At boot, one `GET /health` on the endpoint's host opens the TLS connection (HTTP/2 where the server offers it), so the first decision skips the handshake; no credentials are sent and a failure is ignored (global section only). `scripts/bench/jev-first-call.sh` measures the first-call difference. |
| `supervisor_every_steps` | `4` | The supervisor's loop check runs once every this many settled depth-0 steps. |
| `supervisor_max_continuations` | `1` | How many times the supervisor may block a stop with a continuation per human prompt; `0` never blocks. A continuation after the first is asked about only when an `edit`, `write`, `apply_patch` or `bash` call ran since the last one. |
| `permissions`, `output_view`, `supervisor`, `triage`, `tools`, `relevance`, `visibility` | `shadow` once enabled | The per-point modes (the project section may set them). `permission` and `tool_tiers` are accepted for `permissions` and `tools`. |
| `thresholds` | the table above | Global section only. |

### What is sent

A state is **named fields**, never a concatenated prompt, and the questions refer to them by
backticked path; nothing from the state is spliced into a question.

| Point | State fields |
|---|---|
| `permission` | `task` (your latest prompt, at most 4,000 bytes), `tool`, `arguments.command`, and `arguments.workdir` when the call names a working directory. Never the model-written description of the command. |
| `output_view` | `step_intent` (the last 600 chars of the assistant text before the call), `tool`, `output.blocks[]` (`id`, `lines`, `text` ≤ 2,000 chars), trimmed to fit 48,000 bytes. |
| `supervisor` | `goal` (≤ 1,500 chars), `step_count`, `open_todos`, `last_steps[≤ 6]` (`tool`, `args` ≤ 200 chars, `result` ≤ 300 chars — an error's message for a failed call —, `exit` for a call that reports one such as `bash`, and `failed: true` for a call that ended in an error; both keys only when present), `final_text` (the last 600 chars). |
| `triage` | `command` (≤ 500 chars), `exit_code`, `step_intent`, `output` (head and tail, at most half the budget — 12 KB at the default). |
| `tool_tiers` | `prompt` (≤ 2,000 chars). |
| `relevance` | `prompt` (≤ 2,000 chars), in one request; skill bodies are never sent. |
| `visibility` | `goal` (≤ 1,500 chars), `open_todos[≤ 20]`, `parts[≤ 255]` (`id`, `tool`, `args`, `excerpt` ≤ 400 chars of the old tool result), trimmed to fit 48,000 bytes. |

- **Sticky keep-sets (points 5 and 6, `act` only).** The tool list and the skill block are decided
  at a session's first prompt and kept. A later prompt that shares vocabulary with the previous
  one reuses both and sends nothing: it has fewer than four words (lowercase `a–z`, `0–9`, `_`
  runs of four or more characters, not one of `please with this that from into then make also just
  should could would need want file files code change update fix`), or at least a fifth of its
  words appear in the previous prompt. Each point whose set came from a decision then writes one
  `acted` row (a point that never asked, for a small catalogue or roster, writes none), verdict `sticky`,
  reason "keep-set reused: the prompt shares vocabulary with the previous one". A prompt that
  shares no vocabulary is decided as usual and its keep-set is added to the old one, so a
  session's tools and skills only grow. In `shadow` every prompt is asked about, and no sticky row
  is written.
- **Paths outside the project.** There is no scope question; the bash gate marks an ask
  `paths_outside_project: true` and the advisor sends nothing then (a skip row, `a path outside
  the project`). The check runs on the call's `workdir` and on every word of the command that is a
  path: it starts with `/` or `~`, contains `/`, or is `..` — relative ones resolved against the
  workdir, the value of `--key=value`, `key=value` and `-I/path` taken as the path, a leading
  redirection (`2>`, `>>`) dropped, a URL (`://`) and `/dev/null` ignored, and a `$VAR/…` word,
  which cannot be resolved, counted as outside. It is lexical, like the external-directory prompt:
  a symlink out of the project is not followed.
- **Head/tail truncation.** A long text field keeps its head and tail around a
  `[truncated N bytes]` marker. Point 1 never advises on a truncated state; the other points only
  record it.
- **48,000 bytes is the hard cap on any state.** The endpoint was measured to accept about 33k
  input tokens — a 60,334-byte state answered, a 61,600-byte one was refused — and dense tool
  output runs about 1.7 bytes per token, so no state is ever sent above 48,000 bytes of JSON. A
  state the endpoint still refuses as too long comes back as HTTP 503 with error code 7003 ("User
  Input Error"); the client records that as `invalid`, never retries it and never counts it
  towards the back-off.
- **The injection guard.** Before any call, every string of the state is normalised (case,
  fullwidth letters, invisible and bidi characters, whitespace) and matched against phrases that
  address the judge rather than describe the world — "ignore the above instructions", "note to the
  classifier", "you are Jev", "this command is safe", … A match skips the call and records
  `injection`. Your own words — the top-level `task`, `goal` and `prompt` fields — are exempt. The
  tool and skill descriptions points 5 and 6 rank are question options, which the guard does not
  scan.
- **No content filter yet.** States carry raw text: the command, up to 255 output blocks, six step
  results, up to 12 KB of failing output, up to 255 excerpts of old tool results. That can include
  a secret from a file the dotenv rule does not cover (`.dev.vars`, `*.pem`), and the decision log
  keeps every state on disk (see [Reading decisions](#reading-decisions)). Turn `jev.enabled` on only in repositories whose tool output may
  leave the machine.
- **Where it goes.** Through the PlungeAI worker the request is answered by Workers AI through
  Cloudflare AI Gateway, billed to the PlungeAI account with gateway logging on — file contents
  leave the machine. The worker can also relay to OpenRouter; OceanCode never uses that: every
  request to the worker has `backend=cloudflare`, and an answer whose `x-typesafe-backend` header is
  missing or names another backend is refused. TypeSafe states that it does not train on requests.
  This is why a repository must opt in with its own `jev.enabled`.

### Reading decisions

- **`oceancode inspect`** shows one `jev` row per point: the mode as its layer, and the endpoint
  host (or `off`) as its source. See [09 · Capabilities](#capabilities-and-plugins).
- **`jev-decisions.jsonl`.** Every decision a point makes — acted on or not — is one JSON line in
  `<ledger root>/<session id>/jev-decisions.jsonl`, beside the session's ledger folder (the ledger
  root is `<data dir>/session-ledger` unless `ledger.dir` sets it). A row carries `point`, `mode`,
  `outcome`, `verdict`, `baseline` (today's behaviour), `latency_ms`, `server_ms` (the worker's own time for the call, from
  its `Server-Timing` header; absent when the endpoint sent none), `model`, `input_tokens`,
  `state_hash` (a hash of the full state), `state` (the state itself, or for one over 8 KiB a
  head/tail excerpt — the full state is then written once to
  `<ledger root>/<session id>/jev-states/<state_hash>.json`, so every decision can be re-sent) and
  `answers` —
  the full answers with every probability, or for a skipped or failed decision the reason as a
  string. It never carries the key, the endpoint or a header value, and a URL in an error message
  is replaced by `<url>`. Writing is best effort: a full disk never fails a decision (the first
  failure is logged once).
- **Outcomes.** `acted` (the verdict changed something), `shadow` (recorded only), `fallback` (the
  decision kept today's behaviour: a low-confidence answer, a skip such as a command over 4,000
  chars, or an answer that could not be applied), `held` (point 1's deterministic hold kept an
  allow answer at ask), `unavailable` (deadline, transport, HTTP error, rate cap or back-off
  window), `invalid` (an answer missing a key, of the wrong type or with bad probabilities; a
  state over its budget; the 7003 refusal), `injection` (the guard skipped the call) and `off`
  (the decider behind the point was off).
- **The triage re-run note.** When point 4 re-runs a command, its decision row is written first,
  with the state Jev was asked about, and a second row follows with outcome `acted` and the reason
  `rerun exit=<code>` or `rerun refused: <why>` as its `answers` string. Only rows whose `answers`
  is an object are decisions; count and label those. A missing-binary or missing-module failure
  (the `env_missing` signals in the point 4 row above) is written as one row with the reason
  `deterministic env_missing` as its `answers` string and no request: `acted` / `hint` once the
  hint was appended, `fallback` / `pass` for a failing install command, `shadow` / `hint` in
  `shadow`. The hint line is "jev triage: `<name>` is not available (exit <code>); use what the
  project already has — `<alternative>` is available and do not install anything the task did not
  ask for" (the alternative clause only when one is on `PATH`; "a command" in place of the name when
  only the exit code said so, and for an `env_missing` answer Jev gave itself).
- **Labels.** When you answer a permission prompt that Jev advised on, a label row is appended to
  the same file — `allow` for once or always, `deny` for reject — and joined to its decision. Only
  a person's answer is a label: the replies headless `oceancode run` gives by itself (`--auto`'s
  once, a Jev-advised reject, the reject without `--auto`) and the Ocean pager's own answers (its
  YOLO auto-approve, the queue it approves when you switch YOLO on, a cancelled or failed prompt)
  write none. `oceancode jev labels` exports every labelled decision.
- **Labelling by hand.** Points 2–7 have no prompt to answer, so their rows come unlabelled.
  `oceancode jev labels --unlabelled --out to-label.json` (optionally `--point <p>`) writes them
  with `"label": null` and `"source": "unlabelled"`. An annotator fills each `label` in the grammar
  of `bench/jev/README.md`, sets `source` to `annotator-<n>` (a second annotator adds
  `second_label`), and the rows are appended to `bench/jev/labels.json` by hand, for example
  `jq -s add bench/jev/labels.json to-label.json > merged.json && mv merged.json bench/jev/labels.json`.
  `oceancode jev labels` keeps every row already in that file and adds only decisions whose point
  and state hash it does not carry, so a later export never duplicates an annotated row. An
  unlabelled row may carry `"evidence": {"spill_grepped": true}`: its turn later `grep`ped or `read` a
  file under `tool-output/`, so the full output was wanted after all. It is a hint for you, never a label.
- **Implicit labels.** `oceancode jev labels --implicit --out bench/jev/labels.json` labels the rows
  nobody labelled from what the agent did next in the session's transcript: the first `skill` it loaded
  after a relevance pick (`skill:<name>`, source `implicit-skill-load`), the tools it fetched with
  `tool_search` after a tier pick (`wide;extra:<tool>` or `narrow;extra:<tool>`, `implicit-tool-search`),
  and whether it made an `edit` / `write` / `apply_patch` / `bash` call after a supervisor continuation
  (`continue`, else `finish`, `implicit-continuation`). Rows follow the same merge as any export. These
  are the agent's behaviour, not your verdict, so spot-check them before a run counts on them; the
  rules are in `bench/jev/README.md`.

### The `oceancode jev` subcommand

Every verb reads the same two sections and environment that boot reads, from the current
directory. Each ends with its sentinel on the last stdout line — except the two `--json` verbs
and `verdicts`, which keep stdout to JSON and print one `JEV_STATUS_EMITTED points=7`,
`JEV_QUESTIONS_EMITTED points=7` or `JEV_VERDICTS_EMITTED lines=<n>` line on stderr, as
`oceancode inspect --json` does. A failure exits 1.

| Command | What it does |
|---|---|
| `oceancode jev status [--json]` | One `point mode endpoint-host` line per point (`-` when no client is configured), then `JEV_STATUS_OK points=7`. `--json` prints `{points, endpoint, thresholds}` — the modes and the resolved thresholds the bench lanes read before they spend. Makes no call. |
| `oceancode jev probe` | **Live, one call.** Asks one fixed `destructive` noul about `git status --short` at the first point that is not off; prints `model=… latency_ms=… input_tokens=… destructive=…` and `JEV_PROBE_OK`, or `JEV_PROBE_FAIL <error>` and exits 1. With every point off it refuses without a call, naming why — `JEV_PROBE_FAIL jev is off: <reason>`, where the reason is the one that turned the layer off: a missing or disabled section, `OC_JEV=off`, `OC_TYPESAFE_API_KEY` absent for the direct API, a refused plain-http endpoint, an endpoint that is not a URL, or every point's mode set to `off`. |
| `oceancode jev probe --record --point <p> --case <id> --request <req.json> --out <fixture.json> [--expect <json>]` | **Live, one call.** The alias of `scripts/fixtures/jev-record.mjs`: sends the request file's `{state, questions}` through the configured decider — its credential scoping, back-off and backend pin — and merges `{state, questions, response, expect}` into `cases.<id>` of the fixture, keeping every other case. Prints `JEV_RECORD_OK point=<p> case=<id> cases=<n>`, or `JEV_RECORD_FAIL reason=<reason> <message>` and exits 1 (a fixture that holds another point, or does not parse, is refused and left as it is). |
| `oceancode jev labels [--root <ledger dir>] [--out <file>]` | Every decision row with a label, from every `<root>/<session>/jev-decisions.jsonl` (default root: the configured `ledger.dir`, else `<data dir>/session-ledger` — where boot writes the log), appended to `--out` (default `bench/jev/labels.json`). The file's rows are kept exactly as read — their order, their own duplicates and any key you added by hand; a decision is added only when the file does not yet carry its point and state hash. A state over 8 KiB is exported whole from its `jev-states/` file. Prints `JEV_LABELS_OK added=<n> total=<m>`; a label file that does not parse is refused (`JEV_LABELS_FAIL`) and left as it is. |
| `oceancode jev labels --implicit [--root <ledger dir>] [--out <file>]` | The same merge for labels read off the session transcripts (`<root>/<session>/transcript/`): a relevance row gets the skill the turn loaded, a tool-tier row the tools it fetched with `tool_search`, an acted supervisor `continue` row `continue` or `finish` by whether a mutating call followed (see [Reading decisions](#reading-decisions)). Rows a person labelled are left to `labels`. Prints `JEV_LABELS_OK added=<n> total=<m> implicit=<k>`, `k` being the rows it derived. |
| `oceancode jev labels --unlabelled [--point <p>] [--root <ledger dir>] --out <file>` | The same export for the decision rows nobody labelled (`"label": null`, `"source": "unlabelled"`), for annotators (see [Labelling by hand](#reading-decisions)), each with an `evidence` object where the transcript shows what the agent did next. `--out` is required, so unlabelled rows never land in the label set by default. |
| `oceancode jev questions --json` | Each point's question set from the seams' own builders, in the order the seams send them. A choice whose options come from the request is empty and names its source in `options_from` (`state.output.blocks[].id`, `state.parts[].id`, `roster`); the tool-tier catalogue is the built-in tools'. The bench generates its question file from this. |
| `oceancode jev verdicts --in <cases.jsonl>` | For each input line `{point, state, response}`, one output line `{point, verdict}` from that point's own policy at the global section's thresholds: permission `"allow"` / `"ask"` (the hold included; its line also carries `held`, true when the hold fixes the verdict at ask — the bench leaves such a row out of accuracy), output view `{keep}`, supervisor `{stop, steer}`, triage `"pass"` / `"rerun"` / `"hint"`, tool tiers `{wide, extra}` (`null` when the answer is incomplete), relevance `{keep}`, visibility `{protect}`. Makes no call. |

**Money.** `probe` and `probe --record` are the only verbs that call Jev. So does every decision
in `shadow` or `act`: at the rate-card price of `jev-1.13.0` ($0.042 per million input tokens,
output free) a permission question is about 550 input tokens and the largest output-view or
visibility state about 30k, so a long session costs cents. Through the PlungeAI worker the calls
are billed to the PlungeAI account. The live bench lanes refuse to run without `OC_LIVE_CONFIRM=1`.

### Tool tiers and provider routes

With `jev.tools = act` a prompt's request advertises only the core tools, the tools Jev picked and
`tool_search`. A tool the model learns through `tool_search` is called **in the same prompt**,
before the next human prompt advertises it — every tool stays registered, so the engine runs the
call. Pins survive a reused keep-set: a tool `tool_search` returned is added to the sticky list
at the next prompt, exactly as it is added to a freshly decided one. A provider route that refuses
a call to a tool absent from that request's tool list must keep `jev.tools = off`.

No route has been checked yet: the check spends frontier-model money and is run by the owner, once
per wired route, before point 5 is promoted. With `jev.tools = "act"` in the project config (and,
in a default build, `OC_JEV_ENDPOINT` set to the worker URL above unless you hold a TypeSafe key),
confirm `oceancode jev status --json` reports `tool_tiers` as `act` — otherwise the check runs
with Jev off and proves nothing — then run
`oceancode run -m <provider/model> "Use tool_search to look up webfetch, then fetch https://example.com and print its title"`.
The route passes when the run calls `tool_search`, then `webfetch`, and finishes without a provider
4xx; a route that fails is listed here as one that keeps `jev.tools = off`.

### Environment variables

| Variable | Meaning | Default |
|---|---|---|
| `OC_JEV` | `off`, `shadow` or `act`: overrides every Jev decision point's mode (`off` turns the layer off whatever the config says). It never turns the layer on by itself — `jev.enabled` must still be set in both the global and the project config. | unset → the config |
| `OC_JEV_ENDPOINT` | Overrides `jev.endpoint` (the Jev `POST` URL). The only way to use plain `http`, and only to a loopback host (`127.0.0.1`, `localhost`, `::1`). | unset → `jev.endpoint`, else the build default |
| `OC_TYPESAFE_API_KEY` | The TypeSafe API key, sent only to `api.typesafe.ai` (never to another host); never in config; stripped from child processes like every `*KEY*` variable. The direct API without it keeps the layer off. | unset |

The same rows are in [02 · Configuration](#configuration), with every other variable.

### Failure behaviour

A Jev failure is never a turn failure: the point falls back to today's behaviour and writes one
row. A `shadow` call runs in the background (see [Modes](#modes)), so its row — an answer or a
failure — is written when the call ends, under the id it was given when it started; closing the
session waits for the calls still open, at most 2.5 seconds, and a process that is killed
before then loses their rows.

| Failure | Behaviour |
|---|---|
| Endpoint unreachable, DNS, TLS | Fallback; `unavailable`. Counts toward the back-off: the second consecutive deadline or transport failure opens the `jev.backoff_seconds` window, during which no point calls. |
| Deadline exceeded (`jev.deadline_ms`, the response body included) | Fallback; `unavailable`; the request is dropped, the turn goes on. One deadline alone never opens the back-off window; a second in a row does. The deadline of a large output-view or visibility state (points 2 and 7, up to 48,000 bytes) is never counted toward it, so a slow large state cannot silence the other points. |
| HTTP 401 / 403 | Fallback; one warning naming the credential source (`OC_TYPESAFE_API_KEY` or `ocean.headers`); no further requests until restart. |
| HTTP 422 | Fallback; the row carries the server's message. |
| HTTP 408 / 429 / 5xx | Up to 2 retries, 500 ms → 5 s with 25 % jitter, honouring `retry-after` up to 60 s, and only when the delay fits what is left of the deadline; then fallback, and a 429 or 5xx that still fails opens the back-off window at once. |
| HTTP 503 with error code 7003 (state too long) | Fallback; `invalid`; no retry and no back-off. |
| A redirect | Never followed (the headers would follow it off the worker); fallback as an HTTP error. |
| Client rate cap (`jev.max_rpm` HTTP attempts per 60-second window, retries included, per process) | The decision is skipped this time; `unavailable`. The cap does not bound the shared gateway, whose own 429 and the back-off window are the real protection. |
| Answer missing a key, of the wrong type, a choice outside its probabilities, or probabilities outside 0–1 or not summing to 1 ± 0.02 | Fallback; `invalid`. |
| A 200 from the worker whose `x-typesafe-backend` header is missing or not `cloudflare` | Fallback; `invalid`; one warning; no further requests until restart. |
| State over its budget | Each field is cut head/tail and output blocks are trimmed to fit (24,000 bytes by default for points 1 and 3–6, 48,000 for points 2 and 7, never above 48,000); a state that still does not fit is not sent (`invalid`). Point 1 never advises on a truncated command. |
| The injection guard matches | No call; `injection`. Point 1 keeps the ask, and under `--auto` in `act` rejects it. |
| A served model other than `jev.model` | Answered as usual; one warning per process (the thresholds were tuned on the configured id). |

### See also

- [02 · Configuration](#configuration) — the `jev` key and the environment variables.
- [07 · Headless and server](#headless-and-server) — `oceancode jev` among the management
  subcommands.
- [08 · Bounding a long run](#bounding-a-long-run) — the bounds the supervisor never changes.
- [12 · Permissions and sandbox](#permissions-and-sandbox) — the rules and the floor that run
  before the permission advisor.

---

## Platform mode (PlungeAI connectivity)

`oceancode` ships in two builds. The **default build** is the standalone coding agent and contains
no platform code at all. The **platform build** is produced with

```bash
cargo build --release -p ocean-cli --features platform
```

and adds the three PlungeAI couplings: the OceanTunnel daemon (`crates/ocean-app/src/oceantunnel.rs`),
the enrich resolver (`crates/ocean-agent/src/systemcontext/remote.rs`) and the per-step remote
capability packs (`crates/ocean-agent/src/systemcontext/capability.rs`). Everything the `ocean`
config section describes below is inert in the default build.

The `platform` cargo feature is declared on three crates — `ocean-agent`, `ocean-app` and
`ocean-cli` — and is **off by default**. The `OceanInfo` schema itself (every `ocean.*` key)
stays in the leaf crate `ocean-config` in both builds, so an `oceancode.json` with an `ocean`
section parses either way; only the code that acts on it is gated.

### Which build do I have?

`node scripts/gates/check-platform-boundary.mjs --no-build` scans the two release binaries for three
byte-literal markers after a release build and reports `markers_local=0 markers_platform=3` when the
boundary holds; a routine `./check.sh` proves the same boundary on debug builds (`--debug`, which
builds `target/debug/oceancode` under both feature sets and scans between the builds — no release
build needed). At runtime, `oceancode inspect` reports a `platform` row: `compiled in` on a platform
build, `not compiled in` on the default build.

### The "platform: not compiled in" notice

A **default** build that finds `ocean.mode = "platform"` or a non-empty `ocean.endpoint` in
`oceancode.json` prints once at boot and in `oceancode inspect`:

```
platform: not compiled in (ocean.mode or ocean.endpoint is set; rebuild with --features platform)
```

It is a single warning, not an error: the agent still runs, exactly as it does for any user with no
`ocean` section. Rebuild with `--features platform` to make the section take effect. A config without
an `ocean` section prints nothing.

### Config keys

The `ocean.*` keys are serde-only and present in both builds; they take effect only in the platform
build (`crates/ocean-config/src/ocean.rs:15-88`).

| Key | Meaning | Default |
|---|---|---|
| `mode` | `local` or `platform`. The only switch that selects platform behaviour. | `local` |
| `endpoint` | Enrich URL (`GET ?agent=<name>&directory=<dir>` → `{baseline, version}`). | empty |
| `headers` | Headers sent on every platform request (auth lives here). | none |
| `ttl_seconds` | How often a session re-checks the enrich endpoint (0 → 300). | 300 |
| `agents` | Platform-resolved agent names; empty defaults to `ocean-build`, `ocean-plan`. | `ocean-build`, `ocean-plan` |
| `mcp_url` | Auto-registers the platform MCP gateway as a remote MCP server named `ocean`. | empty |
| `capability_endpoint` | Per-step capability pack endpoint. | empty |
| `gateway_base_url` | The inference gateway a provider `baseURL` should point at: the bare host or its `/v1` root, both work. | `https://api.plungeai.com/v1` in platform mode |
| `tunnel_url` | OceanTunnel dial-out URL; with a token, runs the tunnel daemon. | empty |
| `tunnel_token` | Inline OceanTunnel session token. | empty |
| `tunnel_token_file` | File holding the tunnel token (`~/` expands against `$HOME`). | empty |
| `tunnel_agent` | Local agent name registered with the tunnel. | `oceancode` |

**The switch is config-only.** No environment variable selects platform mode; `ocean.mode` in
`oceancode.json` is the only switch — there is no `OCEANCODE_MODE` / `OC_MODE`. Two platform-related
environment variables the binary reads never turn platform mode on by themselves:

- `OC_TUNNEL_TOKEN` — the tunnel session token, read at the **highest priority** (above the deprecated
  `OCEAN_TUNNEL_TOKEN`, then `tunnel_token_file`, then the inline `tunnel_token`). `OCEAN_TUNNEL_TOKEN`
  is still read as a legacy fallback and logs one deprecation warning per process.
- `OC_SSO_TOKEN` — a per-request gateway SSO session token. When set, oceancode attaches it as the `X-Ocean-SSO-Token` header (no `Bearer` prefix) on requests to the configured **https** gateway host only — never a direct-provider host, never over plaintext — and never on `Authorization` (the wallet bearer, your `ozk_` key, stays intact). Read at runtime, never persisted. Server-side reader is a pending platform handoff: until the inference gateway reads `X-Ocean-SSO-Token`, the header is ignored (harmless).

Every variable also has a row in [02 · Configuration](#environment-variables).

### Per-step capabilities

In platform mode the local `<available_skills>` catalog is replaced by a **capability pack** sized to
the plan row the model is working. At each step boundary the resolver asks the capability endpoint for
a pack (eager skill bodies within a 24 KiB budget, a deferred index, and a version); `304` when the
pack is unchanged, so the retained prefix — and the provider's prompt cache — never changes because a
skill changed. Deferred bodies cost nothing until pulled:

- **`load_capability` tool** — a platform-build tool that pulls a deferred capability body by id
  (`load_capability {id}`) into the transcript as a normal tool result, never a file on disk. It is
  registered only in the platform build and refuses any id the platform never offered.
- **`capability` permission** — the permission family that governs `load_capability`. It **asks** by
  default in the read-only agent families (`ask`, `explore`, `security-review`) and is allowed for the
  `build`, `plan` and `general` families.
- **Registry skills** — platform mode can also discover skills straight from the registry with no local
  files; they surface with the provenance `platform:<id>` (tier `platform`, bound tool id
  `skill_platform_<id>`) and load with no base-directory line.

### Model routing and the wallet

**Signing in.** On a platform build, `oceancode login --provider ocean` (alias `plungeai`) signs you in
through the browser and stores your account key. It opens the dashboard's
`https://dashboard.plungeai.com/api/cli/authorize` page with a loopback port, you sign in with any method
the dashboard offers, the dashboard hands a one-time code back to `http://127.0.0.1:<port>/callback`, and
oceancode trades that code for your `ozk_` account key and stores it in `auth.json` under `ocean` — the
entry `oceancode key ocean` writes and platform mode uses first. The key never appears in a browser URL.
`--no-browser` prints the link without opening it; the sign-in gives up after three minutes;
`oceancode logout ocean` removes the key.

With an `ozk_` platform key stored — `oceancode key ocean ozk_…` (it lands in `auth.json`), or a credential
under the `ocean` integration — platform mode synthesizes an `ocean` provider whose `baseURL` is the
gateway's `/v1` root (`https://api.plungeai.com/v1` unless `gateway_base_url` says otherwise), so every model
call routes through the inference gateway. A legacy `sk-ocean-` key is found the same way; when both are
stored the `ozk_` key wins. A model of `auto` is resolved once per session from the gateway's
`/v1/discovery/recommend` (falling back to `anthropic/claude-sonnet-4-5` if the recommend call fails).
The `run --format json` result envelope then carries two extra objects for a platform-routed run:
`platform: { fallback_steps }` (how many steps failed open to the local route) and
`wallet: { balance, spent }` (present only when the gateway meters). A local run omits both.

### The ledger in platform mode

Two engine-owned sections in `SESSION.md` are fed by platform-mode durable events:

- **`## Plan changes by user`** — regenerated from `todo.updated` events, one summary line per event
  that carried user-authored plan rows (the model's own edits contribute nothing).
- **`## Capabilities used`** — regenerated from `pack.manifest` events, one
  `| step | ids | version | hash |` row per step; a fallback step lists its row with an empty id cell.

Both are bounded to the newest 20 rows and regenerated on every write, so they never crowd out
model-owned sections. Plan rows also carry cross-session history: when a row that completed or was
cancelled in an earlier session reappears, its projection shows how often it was seen and how often it
failed.

---

## Testing and gates

Everything in `oceancode` is verified by one script — `./check.sh` — and a handful of opt-in
lanes on it. This page covers what those lanes are, and then the one test surface a terminal
UI needs that unit tests cannot give it: the **interface probe**, which drives the real TUI
over a pseudo-terminal and proves the interface never freezes.

### The lanes

```bash
export PATH="$HOME/.cargo/bin:$PATH"
./check.sh                 # fmt · clippy -D warnings · build · nextest · goldens · doc + gate checks
E2E=1     ./check.sh       # + real-engine PTY tests, chaos, crash-recovery drills, the interface probe
PERF=1    ./check.sh       # + divan benches and the budget gates
COVERAGE=1 ./check.sh      # + llvm-cov with the zero-covered-handler tripwire
MUTANTS=1 ./check.sh       # + cargo-mutants over the risk-ranked modules
```

The default lane dials no model provider and touches no network. Every checker prints a
sentinel as its **last line** and exits non-zero on failure; the full roster is in
`scripts/README.md` (`apps/ocean-coding-agent-rust/scripts/README.md`). No lane spends money — the only live-provider
scripts (`scripts/probes/live-*.sh`) are run by hand, never by `check.sh`.

### The interface probe

A TUI can pass every unit test and still lock up the instant a slash command awaits the whole
turn on the event loop — the exact freeze that shipped and was fixed on 2026-09-15. Unit tests
run the update function in-process; they cannot catch a real event loop that has stopped
turning keystrokes into frames. The interface probe can, because it is black-box: it spawns the
real `oceancode` binary behind a pseudo-terminal, renders its screen with `pyte`, feeds it real
keys, and asserts on what a person would see.

It costs nothing to run. The binary is wired to a **fake Anthropic provider** with a fake key,
an isolated `HOME`, and a loopback `--listen` port; the owner's real provider keys never enter a
probe run.

```bash
bash scripts/probe/run-probe.sh                 # every scenario
bash scripts/probe/run-probe.sh -k slash        # only scenarios whose id (or file) contains "slash"
bash scripts/probe/run-probe.sh -k baseline     # one file's scenarios (-k matches the module name too)
bash scripts/probe/run-probe.sh -j 4            # run 4 at a time, each on its own ports
bash scripts/probe/run-probe.sh --list          # module:id of every discovered scenario
bash scripts/probe/run-probe.sh --keep          # keep each scenario's temp HOME to debug a failure
```

The last line is the sentinel:

```
INTERFACE_PROBE_OK scenarios=10 passed=10 xfail=0        # exit 0
INTERFACE_PROBE_FAIL scenarios=10 failed=1 mcp_roundtrip  # exit 1
```

`run-probe.sh` bootstraps its own Python venv at `target/probe-venv` (`pexpect` + `pyte`,
idempotent and offline-tolerant), resolves the binary from `$OCEANCODE_BIN` — building
`target/debug/oceancode` with `cargo build -p ocean-cli` when it is unset — and runs the
harness. It is wired into the `E2E=1` lane after the crash-recovery drills, so a freeze is a
red build.

#### The liveness oracle

Every scenario ends on the same universal check, `t.assert_live()`, which the harness calls for
you after the scenario body returns:

> type a unique nonce into the composer, assert the composer echoes it within 1.5 s, then erase
> it.

A frozen event loop cannot echo a keystroke, so that one assertion is the proof the interface is
still alive — no matter what the scenario did before it. Because of this, a scenario only has to
assert its own behavior and leave the composer on screen; the liveness check is free.

#### Driving the TUI interactively (for agents)

The same probe also runs as a **persistent session**, so an agent can drive the app step by step
from separate Bash calls — open it, look, type, look again — instead of only inside a batch
scenario. `scripts/probe/session.py` holds one PTY and `pyte` screen in a daemon; a client speaks
to it over a unix socket:

```
session.py open <name> --app oceancode|pager|cli|cmd [--cols 120 --rows 40] [--keep] [-- cmd args…]
session.py <name> screen | styled <row> | history | send <text> | key <k> [k…] | paste <text>
session.py <name> wait <needle> [timeout] | wait_re <regex> [timeout] | resize <c> <r> | alive | requests | evidence <file> | close
```

`oceancode` / `pager` boot the real binary on the fake provider in a fresh `HOME` (the scenarios'
isolation, but colours kept so `styled` can read them); `cli` runs the `cli/` REPL against deployed
Studio. Wait for the app's **ready marker** before driving — `❯` for the pager (`oceancode` boots
Ocean pager too since the native TUI was removed on 2026-10-05), `Ask anything` for the REPL — and **wait for a marker, never sleep**
(`wait` polls the screen every 150 ms).

```bash
S="target/probe-venv/bin/python scripts/probe/session.py"
$S open pager --app pager            # fresh HOME, fake provider, PTY 120×40
$S pager wait '❯' 40                 # block on the ready marker
$S pager send 'hello'; $S pager key enter
$S pager wait 'ok: hello' 40
$S pager styled 38                   # one row as (fg, bg, bold) runs — colour/bold asserts
$S pager close                       # kill app + fixture, remove the temp HOME
```

End every interactive check the way a scenario does: type a unique nonce and confirm it echoes in
the composer — the liveness oracle, by hand. `bash scripts/probe/session-selftest.sh` drives all
three surfaces from separate processes and prints `PROBE_SESSION_OK apps=<n>` as its last line;
`--no-cli` runs the hermetic surfaces only (the pager), which is how the
`E2E=1` lane wires it in.

#### Adding a scenario

Drop a function named `scenario_<name>(t, fx)` into any file under `scripts/probe/scenarios/`.
New files and functions are discovered automatically — you never register anything. `t` is a
live `Tui`, `fx` a `Fixture` on the fake provider. Raise `AssertionError` (or call `t.fail(msg)`)
to fail; a screen excerpt is attached automatically.

```python
# scripts/probe/scenarios/my_feature.py
def scenario_models_open_and_esc(t, fx):
    """/models opens the model picker and Esc returns to a live composer."""
    t.send("/models")
    t.key("enter", 0.6)
    if "MODEL" not in t.text():
        t.fail("/models did not open the model picker")
    t.key("esc", 0.4)
    if t.PLACEHOLDER not in t.text():
        t.fail("Esc did not return to the composer")
    # the harness calls t.assert_live() after this returns
```

**Driving the screen (`t`):**

| Call | What it does |
|---|---|
| `t.send(text)` | type text into whatever has focus |
| `t.key(name)` | one key: `enter`, `esc`, `tab`, `backtab`, `backspace`, `up`/`down`/`left`/`right`, `home`/`end`, `pgup`/`pgdn`, `ctrl-c`, `ctrl-d`, `ctrl-u`, `shift-enter`, `f1`–`f12` |
| `t.paste(text)` | bracketed paste |
| `t.resize(cols, rows)` | resize the terminal (delivers `SIGWINCH`) |
| `t.wait_for(needle, timeout)` | wait until `needle` (a substring or a compiled regex) is on screen; returns `True`/`False` |
| `t.text()` / `t.line(n)` | the whole screen / one row, as text |
| `t.alive()` / `t.pid` | is the process still running / its pid |
| `t.snapshot(label)` | record a labelled screen for later |
| `t.fail(msg)` | raise with a screen excerpt attached |
| `t.assert_live()` | the liveness oracle (also usable mid-scenario) |
| `t.assert_version_bar()` | assert a `vX.Y.Z` build stamp is in the status bar |
| `t.PLACEHOLDER` | the composer's empty-prompt placeholder text |

**Steering the provider (`fx`):** the fake provider picks its reply from MAGIC WORDS in the last
user message — put one in the prompt text your scenario sends:

| Word | Reply |
|---|---|
| *(none)* | one text block `ok: <first 40 chars of the prompt>` |
| `SLOW` | the same text paced over 30 deltas (a visibly streaming turn) |
| `ERR500` | HTTP 500 with an Anthropic error body |
| `HANG` | an open stream that never answers (abort it from the TUI) |
| `TOOLUSE` | a `tool_use` for the `bash` tool on the first turn (the permission dialog appears), text on the follow-up |
| `WORKFLOW` | a `tool_use` for the `workflow` tool launching the built-in two-step `pipeline` (its sub-agents get default replies and the run completes) — the pager's Workflows-dock regression guard |
| `GOAL` | a `tool_use` launching the built-in `goal` supervisor; its planner/skeptic `output_schema` requests are answered with valid JSON so the run converges (Plan → Work → Verify) |
| `MD` | a markdown-heavy reply (heading, table, fenced code, wide CJK + emoji) |

Read what the binary actually sent with `fx.requests()`, `fx.last_request()`, `fx.user_texts()`
(the last-user text of every request), and `fx.base_url` / `fx.port`.

Each scenario is fully isolated: its own `HOME`, its own fixture project directory carrying its
own `.claude/commands/{long,short}.md`, its own two free ports, and its own fresh fixture
process. Scenarios never share state, so `-j N` runs them in parallel safely.

#### xfail policy

A scenario for a **known, tracked** defect is decorated `@xfail("REASON-ID")`:

```python
from scenarios.xfail import xfail

@xfail("OCA-1234-slash-refusal-toast")
def scenario_unknown_slash_is_refused_visibly(t, fx):
    ...
```

It is reported as `XFAIL <REASON-ID>` and does **not** fail the run; a scenario that is
xfail-marked but unexpectedly passes is reported `XPASS` and counts as a pass. Use `@xfail`
only for a defect that has a tracking id — never to silence a flaky or half-finished scenario.
The moment the defect is fixed, remove the decorator so a regression turns the scenario red
again.

---

## Distribution and updates

`oceancode` ships as two self-contained binaries: the engine `oceancode` and the terminal UI
`ocean-pager` it launches, installed side by side. This page covers installing it, keeping it
current, verifying a download, and removing it. Every download is checksummed, and every install
channel agrees on one set of release-asset names.

### Installing

| Channel | Command |
|---|---|
| macOS / Linux (script) | `curl -fsSL https://raw.githubusercontent.com/OWNER/ocean-coding-agent/main/packaging/install/install.sh \| sh` |
| Windows (PowerShell) | `irm https://raw.githubusercontent.com/OWNER/ocean-coding-agent/main/packaging/install/install.ps1 \| iex` |
| Homebrew (macOS / Linux) | `brew install OWNER/oceancode/oceancode` |
| Cargo (Rust-native, npm-free) | `cargo install --locked --path crates/ocean-cli` (from a clone today; `cargo install --locked oceancode` once published) |

The script installer downloads the release asset for your platform, verifies it (see
[Verifying a download](#verifying-a-download)), and installs `oceancode` and `ocean-pager` into
`${OC_INSTALL_DIR:-$HOME/.local/bin}` (Windows `%USERPROFILE%\.oceancode\bin`). If that directory
is not on your `PATH`, the installer prints the line to add. See
[02 · Configuration](#configuration) for `OC_INSTALL_DIR` and the other install knobs.

`cargo install` is npm-free by design — the whole toolchain is Rust; no Node package is pulled at
build or install time. See `packaging/homebrew/cargo-install.md`.

### Keeping current — `oceancode update`

```bash
oceancode update              # update to the newest release for your channel
oceancode update --self-test  # prove the swap + rollback offline (no network)
```

`update` fetches the newest release for the configured channel, **never downgrades**, verifies the
release signatures, swaps `oceancode` and `ocean-pager` atomically, and **rolls both back** if either
new binary fails a health check — so a failed update leaves the working pair exactly as it was.
`--self-test` prints `UPDATE_SELF_TEST_OK binaries=2` as its last line.

Channels and opt-out:

| Setting | Where | Effect |
|---|---|---|
| `update.channel` | config (`oceancode.json`) | `stable` (default) or `beta` (sees pre-releases). |
| `OC_UPDATE_CHANNEL` | env | Overrides `update.channel` for one run. |
| `update.auto_check` | config | `true` (default) checks for a newer release on boot. |
| `OC_NO_UPDATE_CHECK` | env | Any value suppresses the boot-time check. |
| `update.endpoint` | config | Overrides the release-metadata source (default: GitHub Releases). |

**Telemetry-free first run.** The boot-time update check is *always* skipped on the very first run
(and whenever `update.auto_check` is `false` or `OC_NO_UPDATE_CHECK` is set): a fresh install never
phones home before you ask it to.

### Verifying a download

Every release ships a single `SHA256SUMS` manifest and a detached minisign signature
(`<asset>.minisig`) per asset. Verify a download before trusting it — the same check on macOS,
Linux and Windows, with no external tool:

```bash
oceancode verify-release oceancode-macos-aarch64.tar.gz \
  --sha256sums SHA256SUMS
```

It prints `VERIFY_RELEASE_OK file=<name>` on success and refuses (non-zero exit) on any mismatch.
`--pubkey <base64>` overrides the embedded release public key.

**Signing tiers.** Releases are signed with minisign on every platform. When an Apple Developer ID
(macOS) or an Authenticode certificate (Windows) is available, the release *also* carries OS-native
codesigning / notarization. When no such certificate is present, the release ships **unsigned at the
OS level but with `SHA256SUMS` + minisign** — the portable trust root above still applies, and the
installer stays fail-closed (it refuses to install an unverifiable artifact unless you pass
`--insecure-skip-verify` / `OCEANCODE_INSECURE_SKIP_VERIFY=1`).

> **Test key notice.** The public key embedded in `oceancode verify-release`
> (`verify_release::EMBEDDED_PUBKEY`) and in the installers is a **deterministic test key**. The
> release owner must replace it with the real minisign public key before shipping a signed release.

### Uninstalling

```bash
oceancode uninstall            # remove the binary only
oceancode uninstall --purge    # also remove ~/.config/oceancode and the data dir
oceancode uninstall --dry-run  # print the plan, remove nothing
```

`uninstall` removes only what the installer placed. `--purge` additionally removes the app's config
and data directories (the data dir holds `auth.json`, TUI state and the default global session
store) — never your per-project `.oceancode/oceancode.db`. It requires a typed `oceancode`
confirmation unless `--yes`, never follows a symlink out of its own tree, and prints
`UNINSTALL_OK removed=<n>`.

### Windows notes

On Windows the `bash` tool spawns the shell selected by the `shell.kind` config key (default
`auto` → PowerShell). Tool output is normalized for cross-OS stability: `\r\n` and stray `\r`
become `\n`, and backslash paths are shown POSIX-style (`C:/Users/...`) for display only. ConPTY is
the PTY backend, and the console has ANSI/VT processing enabled so escape sequences render.

---

### Release-asset naming contract

Every channel — the script installer, Homebrew, `oceancode update`, `oceancode verify-release`, the
download page and the release CI job — agrees on **one** naming scheme. This is the single source of
truth; a producer or consumer that disagrees is a bug to reconcile against it.

**Per target, the release publishes BOTH:**

- **`oceancode-<os>-<arch>.tar.gz`** — a gzipped tar containing exactly `oceancode` and
  `ocean-pager`. The **script installer** (macOS/Linux) and **Homebrew** download this and install
  both side by side.
- **`oceancode-<os>-<arch>`** — the **bare binary** (Windows: `oceancode-<os>-<arch>.exe`).
  `oceancode update` downloads this and stages it directly as `oceancode.new` for the atomic swap, so
  it must NOT be an archive. On Windows the PowerShell installer also consumes this bare `.exe`.
- **`ocean-pager-<os>-<arch>`** — the bare TUI binary; `oceancode update` swaps it together with
  `oceancode` (the engine runs inside the pager process, so the pair must match). If either fails
  its `--version` health check after the swap, both are rolled back to their `<name>.bak`. A release
  without this asset updates `oceancode` alone and warns that the TUI was not updated.

Plus, for **every** asset (the tarball and both bare binaries):

- **`<asset>.minisig`** — a detached minisign signature, and
- **one `SHA256SUMS`** — a single manifest hashing every asset (no per-target manifest).

**No version appears in any filename** — the version lives in the release tag path and the download
manifest's `version` field.

#### rustc triple ↔ `<os>-<arch>` token

The `<os>-<arch>` token is `oceancode update`'s `platform_suffix()` (`std::env::consts::OS` +
`std::env::consts::ARCH`), matched as a **substring** of the asset name — so the Linux `-musl` suffix
is tolerated. The installer's `detect_asset` (`packaging/install/install.sh`) names match the tarball
column.

| rustc triple | `<os>-<arch>` token | tarball (installer / Homebrew) | bare binary (`oceancode update`) | bare TUI (`oceancode update`) |
|---|---|---|---|---|
| `aarch64-apple-darwin` | `macos-aarch64` | `oceancode-macos-aarch64.tar.gz` | `oceancode-macos-aarch64` | `ocean-pager-macos-aarch64` |
| `x86_64-apple-darwin` | `macos-x86_64` | `oceancode-macos-x86_64.tar.gz` | `oceancode-macos-x86_64` | `ocean-pager-macos-x86_64` |
| `x86_64-unknown-linux-musl` | `linux-x86_64` | `oceancode-linux-x86_64-musl.tar.gz` | `oceancode-linux-x86_64-musl` | `ocean-pager-linux-x86_64-musl` |
| `aarch64-unknown-linux-musl` | `linux-aarch64` | `oceancode-linux-aarch64-musl.tar.gz` | `oceancode-linux-aarch64-musl` | `ocean-pager-linux-aarch64-musl` |
| `x86_64-pc-windows-msvc` | `windows-x86_64` | `oceancode-windows-x86_64.tar.gz` | `oceancode-windows-x86_64.exe` | `ocean-pager-windows-x86_64.exe` |

Because the `.tar.gz` and the bare binaries carry the same `<os>-<arch>` token, the update path
must select the **bare** binaries specifically (skip `.tar.gz`) and tells them apart by the
`ocean-pager-` prefix; the installer/Homebrew select the tarball.

### Platform download-page contract

When `update.endpoint` is set, `oceancode update` fetches release metadata from the platform's
release endpoint (Track P / S11-T03; the canonical schema lives in
`docs/platform-download-contract.md`). The endpoint returns JSON of this shape — the platform and
the binary agree on the artifact list here:

```json
{
  "version": "2.6.0",
  "channel": "stable",
  "artifacts": [
    {
      "target": "aarch64-apple-darwin",
      "url": "https://…/oceancode-macos-aarch64",
      "sha256": "…",
      "minisig": "https://…/oceancode-macos-aarch64.minisig"
    }
  ]
}
```

`version` and `channel` are strings; `artifacts` is one object per shipped target. `url` is the
**bare binary** `oceancode update` downloads and stages as `oceancode.new` (not the `.tar.gz` — that
is the installer's), `sha256` matches its `SHA256SUMS` entry, and `minisig` is its detached
signature. This page documents the shape only; the endpoint itself is the platform's (Track P,
`docs/platform-download-contract.md`), not the binary's.

### Release checklist

An ordered, mostly-automated release. Agents never publish or tag — the owner does the final GitHub
Release and tap steps.

1. **Bump the version.** `bash scripts/release/bump-version.sh <X.Y.Z>` — moves the `## Unreleased`
   CHANGELOG section under the new dated heading and updates `VERSION`. This release is a **minor**
   (new subcommands + platforms): `2.5.3` → **`2.6.0`**, bumped 2026-09-15. Since then every
   `scripts/release/build-release.sh` run bumps the patch on its own (`2.6.01`, `2.6.02`, …): a
   build number is never reused, and the check.sh lanes build with `NO_BUMP=1`.
2. **CHANGELOG dated.** Confirm the moved section reads correctly under `## 2.6.0 — <date>`.
3. **User-guide tables current.** `node scripts/gates/check-user-guide.mjs` → `USER_GUIDE_OK … missing=0`
   (every `OC_*` var and subcommand has a row) and `node scripts/gates/check-changelog.mjs` →
   `CHANGELOG_OK`.
4. **Whole-tree green on three OSes.** `./check.sh` → `ALL CHECKS PASSED` on macOS, Linux and Windows in CI.
5. **Build the five targets.** `bash scripts/release/build-all.sh` (or the CI `release` job's matrix)
   → `BUILD_RELEASE_OK targets=5`. Per target publish **both** the bare `oceancode-<os>-<arch>`
   (Windows `.exe`) and the `oceancode-<os>-<arch>.tar.gz`, using the `<os>-<arch>` tokens in the
   mapping table above — no version in any filename.
6. **Per-artifact budgets.** `RELEASE=1 ./check.sh` (or the CI native legs) runs `size-gate.sh` per
   artifact (≤ 26 MiB → `SIZE_GATE_OK`) and `boot-bench.sh` (< 36 ms → `BOOT_BENCH_OK`).
7. **Sign.** `bash scripts/sign/sign-artifacts.sh` (minisign → `SIGN_OK artifacts=<n>`); where
   certificates exist, `scripts/sign/codesign-macos.sh` (Apple notarization) and
   `scripts/sign/authenticode-windows.ps1` (Windows Authenticode).
8. **Checksums.** `bash scripts/release/gen-sha256sums.sh dist` → `SHA256SUMS_OK count=<n>` over the
   packaged assets.
9. **Draft the GitHub Release** (owner): upload the assets, `SHA256SUMS` and each `.minisig`.
10. **Homebrew tap** (owner): mirror `packaging/homebrew/oceancode.rb` to the tap with the real
    `sha256`s / bottle digests.

#### Owner-only steps (agents never do these)

- Replace `verify_release::EMBEDDED_PUBKEY` and the installers' embedded minisign pubkey with the
  **real** release key.
- Provision the CI secret `MINISIGN_SECRET_KEY` and install `minisign` on the release runners.
- Relocate `docs/plans/ci/oceancode.yml` → `.github/workflows/oceancode.yml`.
- `git tag` / publish the GitHub Release; push the Homebrew tap; `cargo publish`.

### Release-notes template

```md
# oceancode v<X.Y.Z>

## Highlights
- <the one or two changes users will notice first>

## Install
- macOS/Linux: `curl -fsSL <install.sh> | sh`
- Windows: `irm <install.ps1> | iex`
- Homebrew: `brew install OWNER/oceancode/oceancode`
- Cargo: `cargo install --locked oceancode`

## Verify
Download the asset for your platform and run:
`oceancode verify-release <asset> --sha256sums SHA256SUMS`

## Changed
- <user-visible changes from the CHANGELOG's dated section>

## Fixed
- <fixes from the CHANGELOG>

## Checksums
See `SHA256SUMS` and the per-asset `<asset>.minisig` attached to this release.
```

### See also

- [01 · Getting started](#getting-started) — the install one-liners and first run.
- [02 · Configuration](#configuration) — `update.*`, `shell.kind`, `OC_UPDATE_CHANNEL`, `OC_NO_UPDATE_CHECK`, `OC_INSTALL_DIR`.
- [07 · Headless and server](#headless-and-server) — `update` / `verify-release` / `uninstall` as management subcommands.

---

## Operations

Running `oceancode` for a long time — days, not minutes — is a first-class case. This page
covers the operational surface that keeps a long run healthy: a metrics scrape, database and
ledger retention, the storage doctor, crash recovery, the event-bus drop counter, and log
levels. Everything here is off or conservative by default; you opt in.

The backup, restore and crash-recovery *procedures* live in the
storage-and-recovery runbook (`apps/ocean-coding-agent-rust/docs/RUNBOOK-storage-and-recovery.md`).

### Metrics (`OC_METRICS_ADDR`)

Set `OC_METRICS_ADDR=host:port` to serve runtime gauges for a Prometheus scrape (uptime,
worker and live-task counts, injection-queue depth, and — on Linux — `oceancode_rss_kb`, the
process resident set from `/proc/self/statm`). Nothing listens by default. The endpoint is a
profiling aid, not a control surface: it exposes gauges, never accepts commands.

The HTTP server also answers `GET /global/health`, which carries `dropped_events` — see
[Event-bus drops](#event-bus-drops-dropped_events) below.

A fresh headless server's resident set is held under the 45 MB SLO by the `PERF=1` lane:
`scripts/gates/rss-fresh.sh` boots one release-binary `serve` on an ephemeral loopback port and
samples its RSS (`RSS_FRESH_KB`), which `assert-budgets.mjs` gates against the ceiling.

### Retention and vacuum

Two independent stores grow over a long run — the session database `oceancode.db` and the
Session Ledger directory — and each has its own retention key. Both sweep hourly with the
first tick at boot, and both are **off by default** so an upgrade never deletes a user's
history on first launch.

| Key | Default | Effect |
|---|---|---|
| `database.retention_days` | `0` (off) | Top-level sessions whose whole tree has been idle longer than this many days are deleted hourly, exactly as a user delete would (messages, event log, share URL go with them). `0` disables the sweep. `30` is the ledger-symmetric opt-in. |
| `database.vacuum` | `true` | Run `VACUUM` after a sweep that removed something, so the file shrinks on disk instead of only freeing pages internally. |
| `ledger.retention_days` | `30` | Session-ledger directories idle longer than this are pruned hourly (parent-aware). |

Set retention in `oceancode.jsonc`:

```jsonc
{
  "database": { "retention_days": 30, "vacuum": true },
  "ledger":   { "retention_days": 30 }
}
```

A runtime config write re-arms the sweeps at the next start.

### `oceancode doctor --storage`

`oceancode doctor` reports orphaned sessions and captured panic reports (read-only; it boots
nothing). Add `--storage` for a storage report:

```
$ oceancode doctor --storage
…orphan / panic report…
storage:
  oceancode.db 4194304 bytes (wal 32768 bytes)
  sessions=128
  ledger 2097152 bytes, oldest 12 days
  restore is offline-only: stop oceancode before restoring a backup
```

`--storage` is a `doctor` flag, not a separate subcommand. The report is best-effort: it
renders zeros for a path that does not exist yet.

### Backup and restore

Backups are consistent even under concurrent writes (`VACUUM INTO`, not a raw file copy), and
restore verifies the backup's `PRAGMA integrity_check` before it swaps anything into place.
Restore is **offline-only** — it replaces the live database file, so stop `oceancode` first.
The step-by-step commands are in the runbook (`apps/ocean-coding-agent-rust/docs/RUNBOOK-storage-and-recovery.md`).

### Crash recovery

A panic writes a timestamped report under the data dir's `crash/`, and an active-sessions
registry records a session whose process died without a clean exit. The next boot prints a
one-line stderr notice when it finds an orphan; `oceancode doctor` lists both. A crashed
session resumes with `oceancode run --continue` — the Session Ledger is intact byte-for-byte
across a `kill -9`. Full drill in the runbook (`apps/ocean-coding-agent-rust/docs/RUNBOOK-storage-and-recovery.md`).

### Event-bus drops (`dropped_events`)

Each event subscriber has a bounded queue. When a subscriber falls behind, the bus's default
behaviour is to evict the oldest queued event and count the drop rather than block the
producer — so a slow consumer never stalls the streaming hot path. The running total is
visible as the `dropped_events` field on `GET /global/health`.

Two `events` config keys tune this:

| Key | Default | Effect |
|---|---|---|
| `events.sub_capacity` | `1024` | Per-subscriber event-queue depth. A deeper queue tolerates a slower subscriber before it drops. `0` falls back to the built-in default. |
| `events.mode` | `drop_oldest` | Overflow behaviour. `drop_oldest` evicts the oldest event and counts the drop (the streaming default). `block_until` briefly parks the publisher (up to one second) for a free slot before falling back to drop-oldest — bounded back-pressure for a deployment that would rather slow a producer than lose events. |

The bus is sized from the project-tier `events` config at boot.

### Log levels (`OC_LOG`)

The engine logs through a tracing subscriber that **redacts secret-shaped values before any
write** (Anthropic / OpenAI / AWS / PEM / bearer / assignment shapes), so a token never lands
in a log file. Control it with `OC_LOG` or the `obs` config block.

| Setting | Default | Effect |
|---|---|---|
| `OC_LOG` (env) | unset → `obs.log_level` | Level: `trace`, `debug`, `info`, `warn`, `error`. Overrides `obs.log_level`. An unrecognized value logs a one-line notice and falls back to `info`. |
| `obs.log_level` | `info` | The configured level when `OC_LOG` is unset. |
| `obs.log_rotate_bytes` | `0` → 10 MiB | Byte ceiling before the live log file rotates to `<file>.1`, so logs never grow unbounded on a multi-day run. |
| `obs.log_file` | unset → stderr | Log sink path for the **headless** modes (`oceancode serve` / `run`). Absent logs to stderr. See the sink-precedence note below. |

The level (`OC_LOG` → `obs.log_level` → `info`) applies to every mode. The `obs` config is read
from the **run's project directory** (the `--dir` a `serve`/`run` was given, else the current
directory), the same way boot reads project config (`ocean_config::load`, honouring
`$OC_CONFIG_DIR`) — so `oceancode run --dir /path/to/project` uses that project's `obs` settings,
not the shell's cwd. A malformed or absent config falls back to defaults and never blocks startup.

**Sink precedence.** `obs.log_file` (bounded by `obs.log_rotate_bytes`) redirects the default
**stderr** diagnostic sink of the headless `oceancode serve` / `run` modes to a size-bounded
rotating file, so headless logs never grow unbounded on a multi-day run. The interactive shape is
Ocean pager, which owns the terminal and keeps its own state under its seeded `$OC_PAGER_HOME` (see
[19 · Ocean pager](#ocean-pager--the-terminal-ui)); `tui.log` is no longer written. So the jsonc example below
applies to `serve` / `run`:

```jsonc
{ "obs": { "log_level": "debug", "log_file": "/var/log/oceancode.log", "log_rotate_bytes": 10485760 } }
```

### Endurance soak (operators)

A deterministic soak drives 1000 metering-fixture steps (no live model) and grades per-step
p99 latency and RSS growth against pinned budgets. Write the soak record to its own path so a
short marathon-fixture run cannot clobber it (`soak-metrics.mjs` grades any record under 1000
steps as `SOAK_RED reason=short`):

```bash
STEPS=1000 TURNS_OUT=docs/results/soak/turns.jsonl bash scripts/bench/marathon-fixture.sh --steps 1000
node scripts/bench/soak-metrics.mjs docs/results/soak/turns.jsonl        # → SOAK_OK / SOAK_RED
```

`OC_SQLITE_CACHE_KB` and `OC_READ_POOL_SIZE` bound per-connection SQLite memory and the read
pool for a measurement run; leave both unset in normal operation. See
[02 · Configuration](#test-and-bench-knobs).

---

## Ocean pager — the terminal UI

Ocean pager is `oceancode`'s terminal UI. A plain `oceancode` execs Ocean pager (`ocean-pager`); the pager and the Ocean engine run in **one process** over typed ACP channels — no HTTP between UI and engine. `--db`, `--directory`, `--listen` and `--verbose` are passed through.

The first-party ratatui TUI was removed on 2026-10-05 (its guide pages are in `archive/native-tui/docs/`). For compatibility `--tui pager` and `OC_TUI=pager` are accepted and change nothing (`--tui grok` is refused as unknown); `--tui ocean` and `OC_TUI=ocean` fail with "the native TUI was removed; `oceancode` starts Ocean pager". The pager's own slash commands, keys and settings are in [Ocean pager reference](#ocean-pager-reference).

### Binary

1. Build the pager: `bash scripts/release/build-pager.sh` (or `cd ocean-pager && cargo build --release -p ocean-pager-bin --locked`) → `target/release/ocean-pager`, the binary `oceancode` launches.
2. A plain debug build lands in `target/debug/ocean-pager` and is never launched. To run a pager from elsewhere, set `OC_PAGER_BIN`.

Discovery order: `OC_PAGER_BIN` → `ocean-pager` beside `oceancode` (and beside the binary a symlinked `oceancode` resolves to) → `ocean-pager` on `PATH`.

`oceancode` sets `$OC_PAGER_HOME` to `<data>/oceancode/pager` (`$XDG_DATA_HOME`, else `~/.local/share`) — the directory a bare `ocean-pager` uses too — so the pager never shares `~/.grok`. Auto-update, remote fetch, feedback and voice are off. Every pager variable: [Environment](#environment).

### Environment

Read by `ocean-pager`, not by the engine. Every pager variable is `OC_PAGER_*`; the old `GROK_*` names are not read.

| Variable | Effect |
|---|---|
| `OC_PAGER_HOME` | The pager's state directory (`config.toml`, sessions, debug logs, the extracted ripgrep under `bin/`). `oceancode` sets it; unset → `$XDG_DATA_HOME/oceancode/pager`, else `~/.local/share/oceancode/pager`. `GROK_HOME` is ignored. |
| `OC_PAGER_LOG_FILE` | Write the pager's debug log to exactly this path (honors `RUST_LOG`). |
| `OC_PAGER_DEBUG_LOG` | A path, or `1`/`true` for `<pager home>/debug/tui-<pid>.txt`: turns the debug log on. |
| `OC_PAGER_CONFIG` | Inline JSON config overlay, merged above `config.toml`. |
| `OC_PAGER_CONFIG_PATH` | An extra JSON or TOML config-overlay file (read by extension), merged above `config.toml`. |
| `OC_PAGER_MANAGED_CONFIG_FAIL_CLOSED` | Overrides the requirements fail-closed setting (applies to `requirements.toml` only). |
| `OC_PAGER_SHELL` | Shell the pager spawns: an executable path (bash, zsh, …); on Windows `pwsh`, `powershell` or `cmd`. |
| `OC_PAGER_THEME` | Force a theme name, or `auto`, without editing the config. |
| `LC_OC_PAGER_THEME` | Same as `OC_PAGER_THEME`; the `LC_` form survives SSH `SendEnv LC_*`. |
| `OC_PAGER_APPEARANCE` | `dark` or `light`: the terminal background `auto` themes assume. |
| `LC_OC_PAGER_APPEARANCE` | Same as `OC_PAGER_APPEARANCE`, SSH-forwardable. |
| `OC_PAGER_TERMINAL_THEME` | Follow the terminal's own palette (on/off; a requirement pin wins). |
| `OC_PAGER_NERD_FONTS` | Nerd Font glyphs in the git status (on/off). |
| `OC_PAGER_FORCE_LEGACY_CONSOLE` | Windows: ASCII-safe glyphs for the legacy console. |
| `OC_PAGER_SCREEN_MODE` | Internal: the screen mode a `/fullscreen` / `/minimal` relaunch hands to the new process (read and removed at startup). |
| `OC_PAGER_SCREEN_MODE_SWITCH` | `exec` forces the legacy re-exec switch between screen modes. |
| `OC_PAGER_SCROLL_SPEED` | Mouse-wheel scroll speed; applied on first load only (the settings modal persists it). |
| `OC_PAGER_SCROLL_MODE` | Mouse-wheel scroll mode; first load only. |
| `OC_PAGER_SCROLL_LINES` | Lines per wheel notch; first load only. |
| `OC_PAGER_INVERT_SCROLL` | Invert the wheel direction; first load only. |
| `OC_PAGER_SCROLL_CADENCE_MS` | Redraw cadence while scrolling (ms). |
| `OC_PAGER_MIN_DRAW_MS` | Minimum interval between frames (ms). |
| `OC_PAGER_DISPLAY_REFRESH_PROBE_ENABLED` | Probe the display refresh rate at startup (on/off). |
| `OC_PAGER_DISPLAY_REFRESH_AUTO_CADENCE` | Derive the draw cadence from the probed refresh rate (on/off). |
| `OC_PAGER_SCROLL_DEBUG` | Show the scroll debug HUD. |
| `OC_PAGER_SCROLL_LOG` | Log scroll input events (unset or `0` = off). |
| `OC_PAGER_FPS` | Show the frames-per-second HUD. |
| `OC_PAGER_MEMTRACE` | Periodic memory trace in the debug log; `0`/`false`/`off` disables. |
| `OC_PAGER_MEMTRACE_INTERVAL_SECS` | Memory-trace interval (seconds). |
| `OC_PAGER_MEMTRACE_THRESHOLD_MB` | Memory trace logs only above this resident size (MB). |
| `OC_PAGER_MOUSE_REPORTING_TOGGLE` | Enable the mouse-reporting toggle (on/off). |
| `OC_PAGER_SHOW_THINKING_BLOCKS` | Show model thinking blocks (on/off; overrides the setting). |
| `OC_PAGER_GROUP_TOOL_VERBS` | Group consecutive tool calls by verb (on/off; overrides the setting). |
| `OC_PAGER_COLLAPSED_EDIT_BLOCKS` | Start edit blocks collapsed (on/off; overrides the setting). |
| `OC_PAGER_CONTEXTUAL_HINTS` | Contextual hints on/off (overrides `[ui.contextual_hints]`). |
| `OC_PAGER_TIPS_OVERRIDE` | Debug builds only: `|`-separated tips that replace the configured ones. |
| `OC_PAGER_SLASH_COMMAND_TAGS` | JSON map of tags shown beside slash commands in the `/` menu. |
| `OC_PAGER_DEFAULT_SELECTED_PERMISSION` | Which permission-card option the cursor starts on. |
| `OC_PAGER_ESC_DOUBLE_PRESS_MS` | Window for the double-Esc gesture (ms). |
| `OC_PAGER_DOCK` | Dock layout on/off (a requirement pin wins). |
| `OC_PAGER_DOCK_V2` | Second dock layout on/off (same precedence as `OC_PAGER_DOCK`). |
| `OC_PAGER_AGENT_DASHBOARD` | `0` turns `/dashboard` off. |
| `OC_PAGER_WORKSPACE_DASHBOARD` | Use the workspace (v2) dashboard layout instead of the persisted v1 layout. |
| `OC_PAGER_SESSION_PICKER_GROUPED` | `1`/`0`: group the session picker (overrides `[cli] session_picker_grouped`, default on). |
| `OC_PAGER_HUNK_TRACKER` | Hunk-tracker mode (overrides `[ui] hunk_tracker_mode`). |
| `OC_PAGER_SUGGESTIONS` | Shell-command ghost suggestions (off by default). They request `ocean/suggest`, which the Ocean engine does not serve, so nothing shows. |
| `OC_PAGER_SUGGESTIONS_AI` | AI variant of `OC_PAGER_SUGGESTIONS` (same limitation). |
| `OC_PAGER_SUGGESTIONS_AI_MODEL` | Model id for `OC_PAGER_SUGGESTIONS_AI`. |
| `OC_PAGER_MCP_PUSH_SERVER_STATUS` | Push MCP server status updates into the UI (default on). |
| `OC_PAGER_ENVRC_TIMEOUT_SECS` | Timeout for evaluating a directory's `.envrc` (seconds; `0` disables). |
| `OC_PAGER_CONNECT_UI_TIMEOUT_SECS` | Seconds before startup shows the connect-failure report (6 s floor). |
| `OC_PAGER_PROMPT_ACK_TIMEOUT_SECS` | Prompt acknowledgement timeout (seconds). |
| `OC_PAGER_EXIT_TIMEOUT_SECS` | Force-exit this many seconds after a quit if teardown hangs (default 20; `0` disables). |
| `OC_PAGER_COPY_FILE` | Backup file every copy is also written to (default `<pager home>/last-copy.txt`). |
| `OC_PAGER_OSC52_SINK` | Emit clipboard writes as OSC 52 for a wrapping terminal. |
| `LC_OC_PAGER_OSC52_SINK` | Same as `OC_PAGER_OSC52_SINK`, SSH-forwardable. |
| `OC_PAGER_CLIPBOARD_NO_OSC52` | Never emit OSC 52 (for hosts that paint it as garbage). |
| `OC_PAGER_CLIPBOARD_NO_NATIVE_READ` | macOS: don't read the native pasteboard. |
| `OC_PAGER_CLIPBOARD_NO_DATA_CONTROL` | Linux: skip the Wayland data-control clipboard protocol. |
| `OC_PAGER_SQLITE_JOURNAL_MODE` | SQLite journal mode for the pager's local databases (the WAL kill switch). |
| `OC_PAGER_RESET_CHILD_OOM` | Linux, internal: set on child processes so they reset the inherited OOM score. |
| `OC_PAGER_EVENT` | Set for notification hook commands: the event name. |
| `OC_PAGER_MESSAGE` | Set for notification hook commands: the message text. |
| `OC_PAGER_SESSION_ID` | Set for notification hook commands: the session id. |
| `OC_PAGER_BUNDLE_RG_PATH` | Build time: embed this ripgrep binary instead of downloading the release tarball. |
| `OC_PAGER_RG_VER` | Build-internal: embedded ripgrep version, set by `ocean-pager-ripgrep/build.rs`. |
| `OC_PAGER_RG_TARGET` | Build-internal: embedded ripgrep target triple. |
| `OC_PAGER_RG_SHA256` | Build-internal: SHA-256 the extracted ripgrep is verified against. |
| `OC_PAGER_CHANGELOG_OFFLINE` | Tests: never fetch the changelog. |
| `OC_PAGER_MDM_NO_EXPAND_TEST` | Tests: macOS managed-preferences layer. |
| `OC_PAGER_TEST_OPEN_URL_FILE` | Tests: write opened URLs to this file instead of launching a browser. |
| `OC_PAGER_TEST_FRAME_WRITE_DELAY_MS` | Tests: delay each frame write (ms). |
| `OC_PAGER_TEST_HOLD_TEARDOWN_SECS` | Tests: hold teardown to exercise the exit timeout. |

### What is wired to the Ocean engine

`/`, `/mcps`, `/skills`, `/plugins`, `/hooks`, `/workflows`, `/model`, and `/effort` use the pager chrome and Ocean `Services`. MCP add/toggle, plugin enable/disable, hook reload, and tool permission cards go through the same ACP methods Grok Build uses. The `/workflows` picker lists built-in and discovered project/user workflows over `ocean/workflows/list` (empty when the `workflows` section is disabled); launching, pausing, resuming and stopping a run go through the `workflow` tool, not an ext request.

**Skills complete in the composer.** Typing `/` and a prefix lists your **enabled**, user-invocable skills alongside the pager's own commands — a plugin skill under Claude Code's plugin-qualified name (`/superpowers:writing-plans`, so `/super` finds every superpowers skill), a project or user skill under its bare name (`/formatcode`); the prefix narrows the list, `Tab`/`Enter` inserts `/<skill> ` into the message box so you can keep typing, and sending dispatches it — the engine expands the skill body into the turn (a typed bare `/writing-plans` still runs the plugin skill too). The candidates come from `ocean/skills/list` (fetched at session start; the Ocean engine does not advertise skills as ACP commands), so a **disabled** skill is never offered. A `Space` toggle on the Skills tab of the capabilities picker updates the popup live, and the `<available_skills>` block and `skill_*` tools shrink on the next step ([09 · Capabilities](#enabling-and-disabling-skills)).

**The capabilities picker.** Each tab (`/skills`, `/plugins`, `/hooks`, `/mcps`, `/workflows`, Marketplace) lists only its own kind. On the Skills tab, `Space` toggles the skill under the cursor and the change sticks (it writes `~/.oceancode/skill-state.json`); `/` opens a search that filters the rows by the skill's name, its slash id, its description and its author, case-insensitively, and a query that matches nothing reads `No matches`. `a` / `d` enable / disable every skill behind a confirm prompt. Each row shows its source — `project`, `user` or `plugin: <name>` — grouped by source.

**Group and master toggles.** On the Skills, Plugins, Hooks and MCP tabs `Space` also works above the single row; every change applies on the next step and costs one cache rewrite ([09 · Capabilities](#enabling-and-disabling-skills)):

| Row / key | Action |
|---|---|
| `Everything` (first row) + `Space` | Master switch: off removes skills, MCP tools, hooks and plugin rules from the agent's context, built-in tools stay; asks first |
| `All skills` / `All plugins` / `All MCP` / `All hooks` (second row) + `Space` | The whole kind on or off; asks first |
| `a` / `d` (Skills tab) | Same as `All skills` on / off |
| A `Plugin: <name>` header (Skills, MCP) or a hook source header + `Space` | Every row in the group; asks first above 10 rows; an all-off group reads `[off]` |
| A row + `Space` | That one skill, plugin, hook or MCP server |

The picker's last row shows the live counts (`skills 22/259 · plugins 2/21 · mcp 1/10 · hooks 8/8 · applies on the next step (one cache rewrite)`), prefixed `EVERYTHING OFF` while the master switch is off. The Plugins tab also lists Claude Code's cached plugins under `User (Claude)`. The **Marketplace** tab reads `~/.oceancode/marketplaces/**` only when you open it (opening `/skills` or `/plugins` no longer touches the marketplace), and marketplace content never reaches the prompt or the tool list.

### Pasted images

Paste an image from the clipboard (`Ctrl+V` / `Cmd+V`) or drag an image file onto the composer; it shows as `[Image #1]` in your text and goes to the model with the prompt ("what's wrong with this layout? [Image #1]"). Every route carries it — Anthropic, OpenAI chat and Responses (xAI, Groq, OpenRouter, …), Gemini. With several images, each is labelled `[Image #N]` in the request, so the model matches the name in your text to the right picture.

Limits: at most 20 images per prompt and **3.75 MiB** per image (about 5 MB once base64-encoded — Anthropic's limit, the strictest of the routes). The engine sends PNG, JPEG, GIF and WebP — the types every route accepts, decided by the image's bytes, not its declared type. The pager re-encodes a TIFF (what a macOS screenshot usually is on the clipboard), BMP, ICO or GIF as PNG before it sends, so those paste and drag in like any other; PNG, JPEG and WebP go as they are. An image over the cap after that, a type the pager cannot decode, or an `http(s)` source refuses the whole prompt with a message naming the image; nothing is resized or dropped silently.

**Text-only models.** When the catalog says the current model reads no images (its models.dev entry lists input modalities without `image`), the text still goes and the image does not; a session note says so: `Image #1 not sent: <model> doesn't accept images`. A model the catalog cannot describe — an offline seed entry or one you declared under `providers.*.models` — gets the image. Switching to a text-only model after pasting drops the media from the request on the next turn — and an image a tool returns (`read` on a PNG) is replaced by a one-line note on such a model — so the provider does not answer 400.

The image is stored inline on the user row of `oceancode.db` (`"files":[{"uri":"data:image/png;base64,…`); a reopened session replays that row as text only, and the headless and server prompt bodies ([07 · Headless and server](#headless-and-server)) carry text only for now.

### Goal mode (`orchestrate: always`)

Set `orchestrate: "always"` in the `workflows` section of `oceancode.json` and a plain prompt is
no longer a single turn — it launches the built-in `goal` workflow, which plans the objective
into verifiable deliverables, works in rounds with one resumed worker, and converges only when a
panel of adversarial skeptics fails to refute completion (identical gaps two rounds running, or a
spent budget, pause instead of looping). The pager's Workflows dock shows the run advancing
through its **Plan → Work → Verify** phases, with the sub-agents of each round beneath it. The
Claude-settings `ultracode` flag does **not** turn this on — only the explicit `orchestrate`
opt-in does. Headless, the same goal run is `oceancode goal "<objective>"` (see
`07-headless-and-server.md`).

### What the pager does not do

Login, billing, worktrees, `/imagine` and Grok memory flush are unsupported (`-32601`).

Connecting a provider is in the pager: pick a gray `/model` row, or `/connect <provider>`. OpenCode-style auth methods:

- **API key** — most providers (Google, Groq, Perplexity, …).
- **ChatGPT Plus/Pro** — OpenAI, via OpenCode’s JSON deviceauth (`auth.openai.com/codex/device`), or paste an API key.
- **Claude Pro/Max** — Anthropic PKCE (paste the browser code), or paste an API key.
- **GitHub Copilot** — GitHub device flow (`github.com/login/device`).
- **claude-code** — `oceancode claude` (Claude CLI login).
- **Bedrock / Vertex** — environment/config, not a prompt.

Already-connected providers open a manage overlay: switch API key ↔ subscription, or **d** to disconnect (removes `auth.json`; env-backed providers stay connected until the env var is unset). Credentials go to the same `auth.json` as `oceancode key` / `oceancode login`.

### Expired subscription

Ocean runs on the Claude subscription by consuming the active slot Claude Code keeps in
`~/.claude/.credentials.json` (`claudeAiOauth`); it never rotates that slot's refresh token. On
macOS, once that file's token is within five minutes of expiry, Ocean also reads the Keychain item
Claude Code refreshes itself into (`Claude Code-credentials`, read only) and uses whichever carries
the later expiry, so a self-refresh by Claude Code no longer strands Ocean an hour later; no file
still means no login. When the slot's access token has expired and carries no refresh token, a
prompt cannot be sent, and the
turn fails in the transcript with a **terminal, actionable** message —
`Claude subscription token has expired: Run oceancode login to reconnect your Claude account.` —
and **no** retry countdown or network banner (the failure is not a transport error and is never
retried). Run `oceancode login`, complete the Claude Pro/Max browser flow, and send the prompt
again. (Headless, `oceancode run` prints the same message to stderr and exits non-zero.)

The same holds when the slot expires **mid-session**: it was valid when the turn started, so the
request reached Anthropic and the server rejected the now-dead token with an HTTP 401. On the
subscription route that 401 (and a 403) is the same terminal `run oceancode login` failure — it is
never retried — whereas a 401 on an API-key provider stays a generic error (its fix is a new key,
not a re-login).

### Coming from the native TUI (removed 2026-10-05)

The pager renames some surfaces of the removed native TUI and has no `ctrl+x` leader — it uses
direct chords. The pager equivalents:

| Native TUI | Ocean pager | Notes |
|---|---|---|
| `/copy` | `/toggle-mouse-reporting` | Toggle terminal mouse capture so text selection/copy works. |
| `/mcp` | `/mcps` | The MCP-servers picker. |
| `/models` | `/model` | The model picker (also gates provider `/connect`). |
| `/cockpit` | status bar + `/context` | No unified cockpit panel; the status bar plus `/context` show the same figures. |
| `ctrl+x` leader chords | direct chords | The pager has no `ctrl+x` prefix. Use `ctrl+p` (command palette), `ctrl+\` (Agent Dashboard), `ctrl+t` (todos pane), `ctrl+m` (multiline / model picker off the prompt). |

### Shift+Tab ring and permission modes

`Shift+Tab` cycles Build → Auto → Plan → Ask → Debug → Build. Build, Plan, Ask and
Debug are the engine's agents (the curated ring in `crates/ocean-acp/src/modes.rs`);
Auto is the one permission stop, Claude Code's "accept edits": the agent stays Build,
the prompt border shows `auto` in yellow, and the engine's permission mode is
`acceptEdits` — edits run without a card, bash and outside-directory asks still
prompt (or go to Jev, see [13](#jev-decisions)), and the `question` tool always
asks. Cycling on from Auto drops back to ask before the switch to Plan. The stop is
absent while always-approve is on, and when the auto feature is gated off
(`[auto_mode] enabled = false` in the pager config).

The same mode is reachable from `/auto`, `/always-approve` (`ctrl+o`; the engine's
`bypassPermissions`, every card auto-answered by the pager) and the `permission_mode`
row of the settings modal. Each change is persisted to the pager config and sent to
the engine as `ocean/yolo_mode_changed`, so the engine's mode and the border flag
agree (see [12 · Permissions and sandbox](#permission-modes)).
A subagent's ask surfaces as its own card, labelled with the subagent.

### Search counter and tideline tiers

The top-right status row shows a `search N · 0.3s` chip once the session has run a
`grep` or `glob` tool call: `N` is the number of calls, the time is the last call's
latency (green ≤ 1 s, yellow ≤ 5 s, orange above). Click it for the breakdown —
calls by tool, last / average / total latency, bytes and files scanned, and how
many recursive `grep -r` / `find` the model ran through bash (and how many of
those were rewritten to `rg`). The figures are folded from each tool result's
metadata (`elapsed_ms`, `files`, `bytes`, `search_redirect`) in `oceancode.db`,
so they survive a restart; nothing is counted in memory.

The Ocean context row (`ctx … N% full · tideline …`) is tiered against the
tideline limit: green under 80 % of it, yellow from 80 %, orange at or over it
(the gauge, the percentage and the tideline clause share the tone). With the
ledger off the same tiers apply against the context window. `tideline ×12 @30% …`
carries the number of turns past the tideline (`/tideline` prints the same
figure); no `×N` while it is zero.

The `bash` tool rewrites a plain recursive `grep -r …` to the bundled ripgrep
(POSIX shells; every flag must have an `rg` equivalent, patterns are literal-safe)
and prepends `[oceancode] ran as: rg …` to the result; any other recursive
`grep` or a `find … -name` runs as typed under a one-line hint naming the grep /
glob tools. A leading `cd <dir> &&`/`;` prefix, a trailing `2>/dev/null`,
`| grep -v …` / `| head` followers, and BRE `\|` alternation are carried or
translated verbatim, so the common slow pipelines still rewrite. The ripgrep directory leads the child `PATH`, so `rg` is available
in bash on every OS where a binary resolves (`OC_RG_BIN` overrides; see 02).

### Ocean session commands

These act on the Ocean engine over `_ocean/*` / `ocean/*` ext methods (Ocean backend only):

| Command | What it does |
|---|---|
| `/tideline` | Bare shows the ledger state; `/tideline now` cuts the ledger; `<n>%`, `<tokens>`, or `default` set the tideline (the floor and the presets were removed — a second number or a preset word is refused with the usage line). The old `/shelve` still works as a hidden alias. |
| `/budget` | Bare shows the spend cap; `$5` (or `5`) caps USD, `off` clears it, `subscription` switches to list-price mode. |
| `/limits` | Bare shows the per-turn bounds; `steps <n>` caps steps, `time <90m\|1h30m>` bounds wall-clock. |
| `/subagent` | Bare reports the effective sub-agent model + source; `<provider/model>` pins one, `default` / `off` / `clear` removes the override. |
| `/plan add\|done\|cancel\|edit\|remove` | Edit the live todo table (1-based rows); bare `/plan` still enters plan mode. |
| `/redo` | Re-apply the turn a `/undo` (`/rewind`) reverted. |
| `/share` | Share this session via a public URL (Ocean backend). |
| `/unshare` | Revoke the current session's share link. |
| `/notify` | Toggle desktop notifications; `/notify on\|off` sets it explicitly. |

---

## Xcode and other ACP editors — `oceancode acp`

`oceancode acp` runs the Ocean engine as an **Agent Client Protocol** (ACP) agent on the process's
stdin/stdout. An ACP editor — Xcode 26.6 and later, Zed, JetBrains, Neovim — starts the binary itself
as a subprocess and talks newline-delimited JSON-RPC over the pipe. There is no URL, no port and no API
key for the editor: the editor is the client, the subprocess is the agent, and the editor asks you for
consent the first time it launches it.

It is the same engine as the pager: the same model and credentials, the same permissions, the same
agents (build · plan · ask · debug), the same ledger and session store. Only the host differs
(`crates/ocean-cli/src/acp.rs:22`, `crates/ocean-acp/src/stdio.rs:38`).

| Flag | Default | Meaning |
|---|---|---|
| `--db <path>` | `oceancode.db` | the session store |
| `--directory <dir>` | cwd | the working directory the engine starts in; the editor's `session/new` `cwd` wins per session |
| `--verbose`, `-v` | off | trace every provider request/response to stderr (credentials masked) |

**stdout is the wire.** Every byte the process writes to stdout is one JSON-RPC message; the banner,
diagnostics and tracing go to stderr. The process exits 0 when the editor closes stdin.

### Xcode

Source: Apple, [Setting up coding intelligence](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence)
— "To enable an agent that doesn't appear in Intelligence settings and supports the Agent Client
Protocol (ACP), click Add an Agent under Agents, enter information in the next sheet, and click Add."

1. Find the binary's absolute path: `which oceancode` (for example `/Users/you/.local/bin/oceancode`).
   Xcode needs the absolute path — no `~`, no relative path, and it validates nothing until first launch.
2. **Xcode › Settings › Intelligence**, under **Agents** click **Add an Agent…** and fill the sheet:

   | Field | Value |
   |---|---|
   | Name | `Ocean Code` |
   | Executable | the path from step 1 |
   | Interpreter | leave blank (`oceancode` is a native binary, not a script) |
   | Arguments | `acp` — one argument per row |
   | Environment Variables | none needed (see Credentials) |

   Click **Add**. Ocean Code now appears under Agents.
3. In the Intelligence sidebar click **New Conversation** and pick **Ocean Code**. The first time,
   Xcode asks whether to allow the agent to run — click Allow.

Do not confuse the Agents sheet with **Chat › Add a Chat Provider**: that door takes a URL (or a local
port) serving `/v1/models` and `/v1/chat/completions` plus an API key and gives a chat box without
tools. The coding agent lives behind the Agents sheet.

### Credentials

Xcode starts the agent through your login shell (observed on Xcode 27: the agent's `PATH` carries
`~/.local/bin`, Homebrew and Xcode's `Developer/usr/bin`), so MCP servers started with `npx` or `uvx`
spawn fine — but do not rely on a shell `export` for the credential. Log in once from a terminal and
the stored credential is used by every Xcode session:

```bash
oceancode auth        # API key or Claude Pro/Max subscription → auth.json in the data directory
```

([01 · Getting started](#getting-started) lists the locations.) A key in `oceancode.json` works
too. The sheet's Environment Variables rows also work, but Xcode writes their values into a plist under
`~/Library/Developer/Xcode/CodingAssistant/ACP/` — prefer the stored login.

Xcode runs the agent with `~/Library/Developer/Xcode/CodingAssistant/<agent-id>/` as its working
directory, so that is where `oceancode.db` — the transcripts of your Xcode conversations — lands;
each session's own `cwd` is the project folder Xcode hands over in `session/new`.

### Xcode's tools

When Xcode starts a session it hands the agent its own tools — build, test, run, SwiftUI previews,
documentation search, diagnostics — as an MCP server (`xcode-tools`) in `session/new`. The engine
registers every server the editor supplies before the session is created
(`crates/ocean-acp/src/mcp_servers.rs:19`, `crates/ocean-acp/src/agent.rs:158`), so the first step
already offers them; the tools are named `xcode-tools_<Tool>` in the registry. Nothing to configure:
"build the project" in an Xcode conversation uses Xcode's build, and Xcode asks before the first
build/test tool runs, as it does for every agent.

For a session you start **outside** Xcode — the pager, `oceancode run` — the same tools come from
Xcode's MCP bridge: in Intelligence settings turn on **Allow external agents to use Xcode tools**, then

```bash
oceancode mcp add xcode --command xcrun --arg mcpbridge
```

with the project open in Xcode ([11 · MCP](#mcp-servers); Apple:
[Giving external agents access to Xcode](https://developer.apple.com/documentation/xcode/giving-external-agents-access-to-xcode)).

### Permissions and questions

A tool call that needs approval becomes an ACP `session/request_permission` with three options —
allow once, allow always, reject (`crates/ocean-acp/src/stdio.rs:136`); Xcode shows it as its own
approval prompt. Cancelling the prompt rejects the tool (`crates/ocean-acp/src/stdio.rs:178`). The
engine's `question` tool has no equivalent in base ACP, so it is rejected and the model asks in text
instead. Always-approve and the Shift+Tab agent ring are reachable through the editor's mode picker
where it has one; Xcode exposes the session modes it is given (build · plan · ask · debug).

### Verify by hand

Two requests on stdin, one reply or notification per line on stdout, exit 0 at EOF — no Xcode needed.
Hold stdin open for a moment: the host returns as soon as the pipe closes, and a request still in
flight at that instant is dropped (an editor keeps the pipe open for the life of the conversation).

```bash
{ printf '%s\n' \
    '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' \
    '{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"'"$PWD"'","mcpServers":[]}}'
  sleep 3
} | oceancode acp 2>/dev/null
```

The first line answers with `agentCapabilities` (`loadSession`, `mcpCapabilities.http/sse`), the last
with a `sessionId` plus the model and mode lists; between them an `_ocean/sessions/changed`
notification — an Ocean extension the pager uses and a plain ACP client ignores. Add a
`session/prompt` line with that id to see the turn stream as `session/update` notifications. This is
what `crates/ocean-cli/tests/acp_mode.rs` pins on every build.

### See also

- [19 · Ocean pager](#ocean-pager--the-terminal-ui) — the terminal UI on the same engine.
- [07 · Headless and server](#headless-and-server) — `serve`, `run`, `goal`, `workflow`.
- [12 · Permissions and sandbox](#permissions-and-sandbox) — the rules behind every approval prompt.

---

## CLI reference

`oceancode` is one binary: `oceancode [subcommand] [flags] [args]`. With no subcommand it starts Ocean
pager. `acp` runs it as an editor agent, and `serve`, `run`, `goal` and `workflow` run it headless.
[01 · Getting started](#getting-started) explains the three boot shapes. This page lists every
subcommand, its flags and its exit codes.

### Flag syntax

Most engine commands parse flags with a port of Go's `flag` package: the default shape, `serve`, `acp`,
`migrate`, `run`, `workflow`, `goal`, `ledger`, `export`, `login`, `logout`, `key` and `auth`. Go's rules
apply:

- `-name` and `--name` are the same flag. A value is written `--name value` or `--name=value`. Single-letter
  flags cannot be bundled: `-vc` is one flag named `vc`.
- Parsing stops at the first argument that is not a flag, or at a bare `--`, which is dropped. Everything
  after that point is positional, even if it looks like a flag.
- A boolean flag never takes the next argument; turn one off with `--name=false`. Integer flags accept
  Go's base prefixes (`0x1000`, `0o10000`, `0b…`) and `_` separators.
- `-h`/`--help` prints `Usage of <name>:` and the flag list to **stderr**, then exits **0**. An unknown
  flag, a missing value or a bad value prints the error and the usage to stderr, then exits **2**.

The capability and maintenance commands (`plugin`, `mcp`, `skill`, `trust`, `hooks`, `jev`, `inspect`,
`doctor`, `update`, `verify-release`, `uninstall`, `claude`) parse their arguments by hand. Write their
flags with two dashes, exactly as shown. Most of them reject `--help` with a usage error (exit 1).
**`update` ignores `--help` and runs a real update**, so never try `oceancode update --help`.

### Global flags

`--version` must be the first argument. `--data-dir` and `--fake-llm-sse` are removed from the whole
command line before the subcommand is chosen, so a `run` prompt that contains the words `--data-dir x`
loses them.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--version` | — | — | Print `oceancode <version> (<build stamp>)` and exit 0. Needs no database, config or terminal. `-v` is **not** an alias: it means `--verbose`. |
| `--data-dir <dir>` | string | the app data dir | Where `auth.json` and the other per-install state live, for commands that boot the engine. Also `--data-dir=<dir>`. |
| `--fake-llm-sse <file>` | string, repeatable | none | Test hook: replay a recorded provider SSE body for each turn, in order, repeating the last one. Also `--fake-llm-sse=<file>`. |

`OC_DETERMINISTIC=1` is read at the same point: it pins the clock and seed that ids are minted from, so
two runs produce identical id sequences. Every invocation except `--version` checks for orphaned sessions
left by a crashed run. When it finds any, it prints one line to stderr: ``note: <n> orphaned session(s)
from a previous run — run `oceancode doctor` for details``.

### Common engine flags

`--db`, `--directory` and `--verbose` are **not** global; each command declares them. They mean the same
thing everywhere, so the per-command tables below omit them.

| Flag | Type | Default | Meaning | Taken by |
|---|---|---|---|---|
| `--db` | string | `oceancode.db` | Path to the session database. | the default shape, `run`, `serve`, `acp`, `workflow run`/`pause`/`resume`/`stop`, `goal`, `ledger`, `export`, `migrate` |
| `--directory` | string | current directory | Working directory. For `workflow runs` it is the project to filter by. | the same, minus `migrate`, plus `workflow runs` |
| `--verbose`, `-v` | bool | off | Trace every outbound LLM provider request/response to stderr, with credentials masked. | the default shape, `run`, `serve`, `acp`, `workflow run` and its control verbs, `goal` |

### Exit codes

| Code | Meaning | Where |
|---:|---|---|
| 0 | Success, or `-h`/`--help` on a Go-flag command. | all |
| 1 | The command failed. The message is printed to stderr as `error: <message>`. | all |
| 2 | Flag parse error (unknown flag, missing or bad value). | Go-flag commands |

`oceancode run` exits with a **typed code** once its turn has started. A failure before that (boot, flag
validation, an empty message) is exit 1.

| Code | Meaning | Stop reason(s) |
|---:|---|---|
| 0 | finished | `finished` |
| 2 | permission rejected | `permission_rejected` |
| 3 | budget | `budget` |
| 4 | time | `time` |
| 5 | no progress | `no_progress`, `max_steps`, `doom_loop` |
| 6 | error | `error`, `hook_blocked`; also a `--json-schema` that cannot be satisfied |
| 130 | interrupt | `user_interrupt` |

`oceancode workflow run` and `oceancode goal` exit by the run's final status:

| Code | Status |
|---:|---|
| 0 | `complete` (and a `--validate-only` check) |
| 2 | `failed`, `cancelled`, `interrupted`, `budget_limited`, or any unrecognised status |
| 3 | a pause: `user_paused`, `back_off_paused`, `no_progress_paused`, `infra_paused`, `blocked` |

Exit 2 from `run` can be a permission rejection or a flag error; the stderr text tells them apart.

### `oceancode` (no subcommand)

```bash
oceancode [--db <path>] [--directory <dir>] [--listen <host:port>] [--tui pager] [-v]
```

Execs Ocean pager, which runs the engine inside its own process. stdout must be a terminal; otherwise it
fails with ``stdout is not a terminal — use `oceancode serve` for headless serving``. The pager binary is
looked up in this order: `OC_PAGER_BIN`, then next to `oceancode` (also next to its target if `oceancode`
is a symlink), then `PATH`. Here `--verbose` becomes the pager's `--debug`.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--listen` | string | `127.0.0.1:4096` | HTTP bind address handed to the pager. A non-loopback address needs `OC_SERVER_PASSWORD`. |
| `--tui` | string | empty | Kept for old scripts: empty or `pager` is accepted, `ocean` is refused (the native TUI was removed), and any other value is an error. Falls back to `OC_TUI`. |

See [19 · Ocean pager](#ocean-pager--the-terminal-ui).

### `oceancode run`

```bash
oceancode run [flags] <message…>
```

Runs one non-interactive turn and exits with the typed code above. **Flags must come before the
message**, because a flag after it is sent to the model as text; a bound flag placed after the message
also prints a warning that the run is not bounded by it. When stdin is not a terminal, `run` reads it to
EOF and appends it to the message, so close stdin (`</dev/null`) when the prompt is only an argument. An
empty message fails with `you must provide a message`.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--agent` | string | empty | Agent to use. |
| `--model`, `-m` | string | empty | Model to use, `provider/model`. |
| `--variant` | string | empty | Model variant (provider-specific reasoning effort, e.g. `high`, `max`). |
| `--title` | string | truncated prompt | Session title. |
| `--continue`, `-c` | bool | off | Continue the last session. |
| `--session`, `-s` | string | empty | Session id to continue. |
| `--resume-title`, `-r` | string | empty | Resume the most recent session whose title matches this text. |
| `--fork` | bool | off | Fork the session before continuing. An error unless `--continue` or `--session` is also given. |
| `--auto` | bool | off | Auto-approve permissions that are not explicitly denied (dangerous!). |
| `--output-format` | string | `text` | `stream-json` (NDJSON per event), `json` (one buffered result object), or `text`. Takes precedence over `--format`. |
| `--format` | string | `default` | Deprecated. `default` = text; `json` = the `stream-json` framing. |
| `--tools` | string | all | Restrict the advertised tools to these comma-separated ids. An unknown id is an error. |
| `--disallowed-tools` | string | empty | Remove these comma-separated tool ids from the advertised set. |
| `--allow` | string | empty | Append allow permission rules, e.g. `"Bash(git *),Edit(src/**)"`. |
| `--deny` | string | empty | Append deny permission rules (last match wins, so a `--deny` beats an overlapping `--allow`). |
| `--append-system-prompt` | string | empty | Append this text to the run's system context. |
| `--json-schema` | string | empty | Validate the model's final JSON against this JSON Schema (a file path or inline JSON). On a mismatch the model is re-prompted with the validation errors, up to a retry limit. Prints the validated object; exits 6 if the schema cannot be satisfied. |
| `--max-usd` | string | unset | USD bound for this session: a number, `off`, or `subscription` (no bound; spend shows the list-price estimate). |
| `--max-minutes` | string | unset | Wall-clock bound: minutes, `90m`, `8h`, `2d`, or `off`. |
| `--max-steps` | string | unset | Step bound per turn, `1..=1000`, or `default` to clear a stored override. |
| `--tideline`, `--shelve` | string | unset | The Session Ledger tideline: `<pct>%` (a share of the model's context), `<limit>`, or `default`. `--shelve` is the older spelling. |
| `--window` | string | — | Removed. Any value fails with `--window was renamed: use --tideline`. |
| `--subagent` | string | unset | The model this session's sub-agents run, `provider/model`. |
| `--atif` | string | empty | When the turn ends, write the session's ATIF-v1.8 trajectory (JSON) to this path. |

See [07 · Headless and server](#headless-and-server) and
[08 · Bounding a long run](#bounding-a-long-run).

### `oceancode serve`

```bash
oceancode serve [--port <n>] [--host <ip>] [--db <path>] [--directory <dir>] [-v]
```

Starts the engine with its HTTP surface only and no UI. The banner goes to stdout and diagnostics to
stderr. A non-loopback `--host` without `OC_SERVER_PASSWORD` is refused before anything boots.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--port` | int | `4096` | TCP port to listen on. |
| `--host` | string | `127.0.0.1` | Interface to bind. A non-loopback host requires `OC_SERVER_PASSWORD`. |

See [07 · Headless and server](#headless-and-server).

### `oceancode acp`

```bash
oceancode acp [--db <path>] [--directory <dir>] [-v]
```

Serves the engine as an Agent Client Protocol agent over stdin/stdout, which is how Xcode and other ACP
editors launch it. stdout carries only newline-delimited JSON-RPC; the banner, diagnostics and tracing go
to stderr. There is no HTTP surface, and the command returns when the editor closes stdin. It takes only
the common engine flags. See [20 · Xcode and other ACP editors](#xcode-and-other-acp-editors--oceancode-acp).

### `oceancode workflow`

```bash
oceancode workflow run <name|path> [--args <json>] [--budget <n>] [--validate-only] [-m <provider/model>]
oceancode workflow runs [--all] [--directory <dir>]
oceancode workflow pause|resume|stop <run_id> [--budget <n>]
```

`run` starts a host-run Rhai workflow and prints each `workflow_updated` body to stdout as one JSON line.
The last line is the terminating body, and the command exits with the workflow/goal code above. Put the
name or path **first**, then the flags. `runs` lists the persisted runs for the current project, newest
first; it takes no `--db` or `--verbose`. `pause`, `resume` and `stop` (run id first) only act on a run
that is live in the engine that same command boots.

| Flag | Sub-subcommands | Type | Default | Meaning |
|---|---|---|---|---|
| `--args` | `run` | string | `{}` | JSON object bound to the workflow's `args` global. |
| `--budget` | `run`, control verbs | string | from config | `run`: absolute bound on cumulative child-agent calls, `1..=1024`. `resume`: a higher call bound that lifts a budget-limited run. |
| `--validate-only` | `run` | bool | off | Metadata and compile check only; launches no agent. |
| `--model`, `-m` | `run` | string | empty | `provider/model` for steps that name no model. There is no default provider. |
| `--all` | `runs` | bool | off | List runs from every project. |

See [07 · Headless and server](#headless-and-server).

### `oceancode goal`

```bash
oceancode goal "<objective>" [--budget <n>] [-m <provider/model>] [--db <path>] [--directory <dir>] [-v]
```

Launches the built-in `goal` workflow (Plan → Work → Verify) and prints each `goal_updated` body to stdout
as one JSON line. It exits with the workflow/goal code above. The **objective must be the first
argument**, with the flags after it. A missing objective, or a flag in first place, fails with
`missing objective: usage: oceancode goal "<objective>" [--budget <n>] [--model <provider/model>]`.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--budget` | string | from config | Absolute bound on cumulative child-agent calls for the goal, `1..=1024`. |
| `--model`, `-m` | string | empty | Default model for every goal agent that names none. There is no default provider. |

See [07 · Headless and server](#headless-and-server).

### `oceancode ledger`

```bash
oceancode ledger path [--db <path>] [--directory <dir>] <session-id>
oceancode ledger rebuild [--merge] [--yes] [--db <path>] [--directory <dir>] <session-id>
```

`path` prints the absolute ledger directory of the session. `rebuild` rebuilds the ledger mechanically and
prints `rebuilt <n> turn(s) in <dir>`. `--merge` then prints the number of merge chunks and asks
`fold forward with model calls? [y/N]` on stderr before folding them forward, which costs model calls.
The session id is read after the flags, so **write the flags first**; a flag placed after the id is not
applied. With the ledger disabled (`ledger.enabled = false`), `rebuild` fails.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--merge` | bool | off | `rebuild` only: fold the merge backlog forward after the rebuild (model calls). |
| `--yes` | bool | off | `rebuild` only: skip the y/N confirmation for `--merge`. |

See [06 · Session ledger and export](#session-ledger-and-export).

### `oceancode export`

```bash
oceancode export (--md | --atif) [--out <path>|-] [--db <path>] [--directory <dir>] <session-id>
```

Writes a whole session as one Markdown document or as an ATIF-v1.8 trajectory (JSON). Pass exactly one of
`--md` or `--atif`. As with `ledger`, the session id is read after the flags, so write the flags first.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--md` | bool | off | Write the session as one Markdown document. |
| `--atif` | bool | off | Write the session as an ATIF-v1.8 trajectory (JSON). |
| `--out` | string | `-` | Output path; `-` writes to stdout. |

See [06 · Session ledger and export](#session-ledger-and-export).

### `oceancode login`

```bash
oceancode login [--provider claude|copilot|codex] [--no-browser]
```

Logs in to a provider and stores the credential in the data dir's `auth.json`. `claude` (the default;
also `anthropic`, `claude-pro`, `claude-max`) runs the Claude Pro/Max OAuth flow: it prints the authorize
URL and reads the pasted code from stdin. `copilot` (`github-copilot`) and `codex` (`openai`) run the
OAuth 2.0 device-code flow and store the credential under `github-copilot` or `openai`.

| Flag | Type | Default | Meaning |
|---|---|---|---|
| `--provider` | string | `claude` | Provider to log in to: `claude`, `copilot` or `codex`. |
| `--no-browser` | bool | off | Print the authorize URL only; do not try to open a browser. |

A `--features platform` build adds `--provider ocean` (dashboard sign-in that stores the Ocean account
key) and `--sso-token <token>` (stores a gateway SSO session token). See
[15 · Platform mode](#platform-mode-plungeai-connectivity).

### `oceancode logout`

```bash
oceancode logout [anthropic|zai-coding-plan|ocean]
```

Removes one provider's stored credential from `auth.json`; the default is `anthropic`. Accepted names:
`anthropic`/`claude`, `zai-coding-plan`/`zai`/`glm`/`zai-coding`/`glmcoding`, and `ocean`/`plungeai`. If
nothing is stored, it prints `No stored <provider> credential.` and exits 0. Takes no flags.

### `oceancode key`

```bash
oceancode key [<provider>] [<api-key>]
```

Stores an API key in `auth.json` (type `api`). The provider defaults to `anthropic` and takes the same
names as `logout`. With no key on the command line, the key is read from stdin when stdin or stdout is
not a terminal; in a terminal, a non-Anthropic provider prompts for it. With no key at all, the command
prints the credential status for `anthropic` and `zai-coding-plan`. A key without the expected `sk-ant-`
prefix triggers a warning but is still stored. Takes no flags.

### `oceancode auth`

```bash
oceancode auth
```

An interactive picker: `1` Anthropic API key, `2` Claude subscription (runs `login`), `3` GLM coding plan
(runs `key zai-coding-plan`). Needs both stdin and stdout to be a terminal and fails otherwise. Takes no
flags.

### `oceancode claude`

```bash
oceancode claude
```

Checks the claude-code provider before you use it. It reports the `claude` binary's path (or `claude.bin`
from config), its `--version`, and whether a Claude Code login is present. Advisory only: it exits 0
whatever it finds. Any argument fails with `usage: oceancode claude`.

### `oceancode inspect`

```bash
oceancode inspect [--json]
```

Prints where every config key, skill, agent, command, MCP server and instruction file comes from, for the
**current directory** (there are no `--dir`/`--home` flags). Each human row is
`kind id winning-layer source`, and the last line is `INSPECT_OK sources=<n>`. With `--json` the JSON
array goes to stdout and the sentinel to stderr. Boots nothing. See
[09 · Capabilities and plugins](#capabilities-and-plugins).

### `oceancode plugin`

```bash
oceancode plugin [--root <home>] <subcommand>
```

`--root` (also `--root=<home>`) overrides the home that the stores live under
(`<home>/.oceancode/plugins` and `<home>/.oceancode/marketplaces`).

| Sub-subcommand | Prints |
|---|---|
| `marketplace add <spec>` / `update <n>` / `remove <n>` | `PLUGIN marketplace-added\|marketplace-updated\|marketplace-removed <name>` |
| `marketplace list` | `<name> plugins=<n>` per marketplace |
| `install <name-or-path> [--trust] [--marketplace <m>]` | `PLUGIN installed <name> <version>`, plus `PLUGIN trusted <name>` with `--trust` |
| `list` | `<name> <version> enabled=<bool> trusted=<bool>` per plugin |
| `uninstall <n>` / `update <n>` | `PLUGIN uninstalled <n>` / `PLUGIN updated <n> <version>` |
| `enable <n>` / `disable <n>` | `PLUGIN enabled\|disabled <n>` |
| `enable-all` / `disable-all` | `PLUGIN enabled-all\|disabled-all count=<n>` |
| `trust <n> [--off]` | `PLUGIN trusted\|untrusted <n>` |
| `validate <n>` | `PLUGIN validated <n> ok=<bool>`, then one `  - <problem>` line per problem |
| `import --from claude-code [--trust] [--no-repo-trust] [--dry-run] [--home <h>] [--dir <d>]` | Ends with `IMPORT_OK plugins=<n> marketplaces=<m> repo_trust=<bool>` (`IMPORT_DRY_RUN_OK …` under `--dry-run`). `import` takes `--home`/`--dir`, not `--root`. |

See [09 · Capabilities and plugins](#capabilities-and-plugins).

### `oceancode mcp`

```bash
oceancode mcp [--root <dir> | --dir <dir>] [--home <h>] <subcommand>
```

`--root` (or its alias `--dir`) selects the project directory where `.mcp.json` lives; the default is the
current directory. `--home` selects the home tier.

| Sub-subcommand | Behaviour |
|---|---|
| `add <name> --command <cmd> [--arg <a>]… [--env K=V]…` | Writes a stdio server to `<dir>/.mcp.json` under `mcpServers.<name>`. Prints `MCP added <name> stdio`. |
| `add <name> --url <url> [--header K=V]…` | Writes a remote (`type: http`) server. Prints `MCP added <name> remote`. Pass exactly one of `--command` or `--url`. |
| `remove <name>` | Deletes the entry from `.mcp.json`. Prints `MCP removed <name>`. |
| `enable <name>` / `disable <name>` | Clears or sets the server's held-disabled bit in the trust store. Prints `MCP_ENABLED`/`MCP_DISABLED <name>`. |
| `list` | One row per discovered server: `name  stdio\|remote  tier  trusted\|untrusted  enabled\|disabled\|shadowed  source`. |
| `login <name>` / `logout <name>` | `login` prints the OAuth callback URL and contacts no network. `logout` clears the stored OAuth entry. |
| `doctor` | Connects to each server with a 2-second budget and prints one status row per server (`connected`, `needs-auth`, `failed`, `disabled`, `untrusted`). |

See [11 · MCP servers](#mcp-servers).

### `oceancode skill`

```bash
oceancode skill [--root <dir>] [--dir <d>] [--home <h>] <subcommand>
```

| Sub-subcommand | Behaviour |
|---|---|
| `create <name> [--description <text>]` | Writes `<root>/.oceancode/skills/<name>/SKILL.md` (`--root` defaults to the current directory) and prints `SKILL created <path>`. Refuses to overwrite an existing skill. |
| `list [--json]` | One row per discovered skill: `id  tier  trusted\|untrusted  state  description`. `state` is `enabled` or the reason the skill is disabled. |
| `enable <id>` / `disable <id>` | Flips one skill's user-tier enable bit. `<id>` is a stable id, or a bare name that matches exactly one skill. Prints `SKILL enabled\|disabled <id>`. |
| `enable-all` / `disable-all` | Prints `SKILL enabled-all` / `SKILL disabled-all count=<n>`. |

See [09 · Capabilities and plugins](#capabilities-and-plugins).

### `oceancode trust`

```bash
oceancode trust show | repo on|off | mcp <name> on|off|clear  [--dir <d>] [--home <h>]
```

Reads and sets the per-project trust record in `<home>/.oceancode/trust.json`. `show` prints
`repo=<bool>`, then one `mcp <name>=<bool>` line per override and one `disabled <name>` line per held
server. Every subcommand ends with `TRUST_OK`. `repo` grants or revokes repo-tier execution (repo hooks
and `.mcp.json` servers). `mcp` sets a per-server override that takes precedence over `repo`, or clears
it. See [11 · MCP servers](#mcp-servers).

### `oceancode hooks`

```bash
oceancode hooks [--dir <d>] [--home <h>] list [--json]
oceancode hooks add <Event> --command <cmd> [--matcher <re>] [--timeout <s>] [--scope user|project|local]
oceancode hooks remove <Event> --command <cmd> [--matcher <re>] [--scope user|project|local]
```

`list` prints every hook source file the boot loader reads: one line per source with its tier, its trust,
whether it is loaded (or held, and why) and its path, followed by its handlers. The output ends with
`HOOKS_OK sources=<n> handlers=<m> loaded=<k>`. `--json` prints `{"repo_trusted", "sources"}` and no
sentinel. `add` and `remove` edit Claude Code's `settings.json` for the chosen scope (default `project`)
and print `HOOK_ADDED <path>` or `HOOK_REMOVED <path>`. Removing a handler that does not exist fails with
`hooks remove: no such handler`. See [10 · Hooks](#hooks).

### `oceancode jev`

```bash
oceancode jev status [--json]
oceancode jev probe [--record --point <p> --case <id> --request <req.json> --out <fixture.json> [--expect <json>]]
oceancode jev labels [--implicit | --unlabelled [--point <p>]] [--root <ledger dir>] [--out <file>]
oceancode jev questions --json
oceancode jev verdicts --in <cases.jsonl>
```

Inspects the Jev decision layer, read from the current directory and the environment (no `--dir`/`--home`
flags). `status` prints one `point mode endpoint-host` line per decision point; `--json` prints a document
instead, with `JEV_STATUS_EMITTED` on stderr. `probe` makes one live call to Jev and prints `JEV_PROBE_OK`
or `JEV_PROBE_FAIL <why>`; `probe --record` records a fixture. `labels` exports labelled decisions
(`JEV_LABELS_OK added=<n> total=<m>`); `--unlabelled` requires `--out`. `questions --json` and
`verdicts --in` print the decision points' question sets and policy verdicts for the bench. Only `probe`
calls Jev. See [13 · Jev decisions](#jev-decisions).

### `oceancode doctor`

```bash
oceancode doctor [--storage]
```

Reports orphaned sessions (registry entries whose process died without a clean exit) and captured panic
reports found under the data dir. `--storage` adds the storage report: the sizes of `oceancode.db` and its
WAL, the session count, and the ledger directory's size and oldest session age. Read-only; any other
argument is ignored. See [18 · Operations](#operations).

### `oceancode migrate`

```bash
oceancode migrate [--db <path>]
```

Opens the database named by `--db` (default `oceancode.db`), migrates it to the current schema, prints
`opened and migrated <path>`, and exits.

### `oceancode update`

```bash
oceancode update [--self-test]
```

Updates itself to the newest release on the configured channel; `OC_UPDATE_CHANNEL` (`stable`/`beta`)
overrides `update.channel`. Both `oceancode` and `ocean-pager` are verified first, swapped atomically, and
rolled back together if a health check fails. Prints `UPDATE_APPLIED from=<old> to=<new>`, or
`oceancode <v> is already the newest <channel> release`. `--self-test` exercises the swap and rollback
offline on stub binaries and prints `UPDATE_OK from=<old> to=<new> rollback_tested=1`, then
`UPDATE_SELF_TEST_OK binaries=2`. No other argument is recognised. See
[17 · Distribution and updates](#distribution-and-updates).

### `oceancode verify-release`

```bash
oceancode verify-release <artifact> [--pubkey <base64>] [--sha256sums <path>]
```

Verifies the artifact's minisign `<artifact>.minisig` signature without any external tool. `--pubkey`
replaces the embedded release key; `--sha256sums` also checks the artifact's entry in that `SHA256SUMS`
file. Prints `VERIFY_RELEASE_OK file=<name>`. See [17 · Distribution and updates](#distribution-and-updates).

### `oceancode uninstall`

```bash
oceancode uninstall [--purge] [--yes] [--dry-run]
```

Removes the installed `oceancode` binary from `OC_INSTALL_DIR` (default `~/.local/bin`). `--purge` also
removes the app's config and data dirs, but never a project's `.oceancode/oceancode.db`. Unless `--yes` is
given, it lists every path and asks you to type `oceancode` to confirm. `--dry-run` prints
`would remove: <path>` lines and removes nothing. The last line is `UNINSTALL_OK removed=<n>`. See
[17 · Distribution and updates](#distribution-and-updates).

### See also

- [01 · Getting started](#getting-started) — the three boot shapes.
- [02 · Configuration](#configuration) — every `OC_*` variable these commands read.
- [07 · Headless and server](#headless-and-server) — `run`, `serve`, `workflow`, `goal` in depth.
- [09 · Capabilities and plugins](#capabilities-and-plugins), [10 · Hooks](#hooks), [11 · MCP servers](#mcp-servers) — the capability commands.
- [17 · Distribution and updates](#distribution-and-updates) — `update`, `verify-release`, `uninstall`.

---

## Ocean pager reference

This page lists Ocean pager's slash commands, keyboard shortcuts, mode ring, `config.toml` settings, themes and
notification options. For how the pager starts, its environment variables and what is wired to the Ocean engine,
see [Ocean pager](#ocean-pager--the-terminal-ui).

### Slash commands

Type `/` in the composer to open the menu. A prefix narrows the list, and `Tab` or `Enter` inserts the command.
Enabled skills appear in the same menu ([19 · Ocean pager](#what-is-wired-to-the-ocean-engine)).

In the **Needs** column, *session* means an active session, *fullscreen* or *minimal* means that screen mode only
(in the other mode the command tells you to switch), and *dashboard* means the Agent Dashboard. *args* means the
command needs an argument.

#### Session and conversation

| Command | Aliases | What it does | Needs |
|---|---|---|---|
| `/new` | `/clear` | Start a new session | — |
| `/resume` | — | Resume a previous session | — |
| `/fork [--worktree\|--no-worktree] [directive]` | — | Branch the current session into a peer agent | session |
| `/rename <title> \| --auto` | `/title` | Rename the current session; `--auto` returns to the automatic title | session, args |
| `/delete` | — | Delete this session | session |
| `/session-info` | — | Show session info | session |
| `/btw <question>` | — | Ask a side question without interrupting the turn | session, args |
| `/recap` | `/summarize` | Summarize the session so far; listed only when session recap is available | session |
| `/rewind` | `/undo` | Rewind to a previous turn | session |
| `/redo` | — | Re-apply the turn a rewind reverted (`ocean/restore_code`) | session |
| `/compact [instructions]` | — | Compact the conversation history (`ocean/compact_conversation`) | session |
| `/queue` | — | List the prompts queued behind the running turn | session |
| `/history` | — | Search prompt history | session |
| `/loop [interval] <prompt>` | — | Run a prompt on a recurring interval | args |
| `/plan [description]` | — | Enter plan mode; `add`/`done`/`cancel`/`edit`/`remove` edit the live todo table (`_ocean/todos/set`) | session |
| `/view-plan` | `/show-plan`, `/plan-view` | View the current plan | session |
| `/share` | — | Share this session via a public URL (`ocean/share_session`); hidden from the menu until enabled, but you can still type it | session |
| `/unshare` | — | Revoke this session's share link (`ocean/unshare_session`) | session |

#### Ocean engine controls

These commands use `_ocean/*` methods. For their argument forms, see
[19 · Ocean session commands](#ocean-session-commands).

| Command | Aliases | What it does | Needs |
|---|---|---|---|
| `/tideline [now \| <n>% \| <tokens> \| default]` | `/shelve` (hidden) | Show or set the Session Ledger tideline (`_ocean/ledger/status`) | session |
| `/budget [<usd> \| off \| subscription]` | — | Show or set this session's spend bound (`_ocean/session/bounds`) | session |
| `/limits [steps <n\|default> \| time <dur\|off>]` | — | Show or set the per-turn step and time bounds (`_ocean/session/bounds`) | session |
| `/subagent [<provider/model> \| default \| off \| clear]` | — | Show or set the sub-agent model (`_ocean/subagent_model`) | session |

#### Model, context and output

| Command | Aliases | What it does | Needs |
|---|---|---|---|
| `/model <name> [effort]` | `/m` | Switch the active model | session, args |
| `/effort <level>` | — | Set reasoning effort for the current model | session, args |
| `/connect <provider>` | — | Connect a model provider (API key or account login) | args |
| `/context` | — | View context usage | session |
| `/usage` | `/cost` | View usage | — |
| `/copy [N] [file]` | — | Copy the last response (or the Nth) to the clipboard or a file | session |
| `/export [filename]` | — | Export the current conversation to a file or the clipboard | session |
| `/transcript` | `/log` | View the conversation transcript in `$PAGER` | session |
| `/find [text]` | — | Search the conversation scrollback | session, fullscreen |
| `/jump` | — | Jump to a turn in the conversation | session, fullscreen |
| `/expand` | — | Re-print the last collapsed block, fully expanded | session, minimal |
| `/edit-prompt` | — | Open an external editor for an empty prompt (to keep a draft, use the command palette) | session |

#### Extensions and workflows

| Command | Aliases | What it does | Needs |
|---|---|---|---|
| `/skills` | — | View skills | — |
| `/plugins` | `/plugin` | View plugins | — |
| `/hooks` | — | View hooks | — |
| `/mcps` | — | Show MCP server status | — |
| `/marketplace` | — | View the marketplace | — |
| `/workflows` | — | Browse installed workflows | — |
| `/workflow` | — | Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save) | session |

#### Settings and display

| Command | Aliases | What it does | Needs |
|---|---|---|---|
| `/settings` | `/config`, `/preferences`, `/prefs` | Open the settings modal | — |
| `/theme [name]` | `/t` | Switch the color theme | fullscreen |
| `/auto` | — | Toggle auto mode (a classifier approves safe tools); listed only when auto mode is available | — |
| `/always-approve` | — | Toggle always-approve (skips all permission prompts) | — |
| `/vim-mode` | — | Toggle vim-style scrollback keys | — |
| `/multiline` | `/ml` | Toggle multiline input (swaps `Enter` and `Shift+Enter`) | — |
| `/compact-mode` | — | Toggle compact UI (less padding) | — |
| `/timestamps` | — | Toggle message timestamps | — |
| `/timeline` | — | Toggle the timeline sidebar | fullscreen |
| `/notify [on \| off]` | — | Toggle desktop notifications ([Notifications](#notifications)) | — |
| `/toggle-mouse-reporting` | — | Toggle terminal mouse reporting, so the terminal's own click-drag copy works | — |
| `/minimal` | — | Switch this session to minimal (scrollback-native) mode | session, fullscreen |
| `/fullscreen` | `/full` | Switch this session to fullscreen mode | session, minimal |

#### Navigation, help and maintenance

| Command | Aliases | What it does | Needs |
|---|---|---|---|
| `/dashboard` | `/agents-dashboard`, `/sessions` | Open the Agent Dashboard; listed only when the dashboard is enabled | fullscreen |
| `/cd [path]` | — | Change the working directory for new agents | dashboard |
| `/home` | `/welcome` | Return to the welcome screen | — |
| `/help` | — | Browse commands and keyboard shortcuts | — |
| `/docs [web\|title]` | `/howto`, `/guides` | Open the in-TUI how-to guides, or (`web`) the published developer guide, `https://code.plungeai.com/docs`, in the browser | — |
| `/release-notes` | `/changelog` | View release notes for the current version | — |
| `/doctor [fix [FIX]]` | `/terminal-setup`, `/terminal-check`, `/terminal-info` | Check this session and show the fixes available | session |
| `/quit` | `/exit` | Quit Ocean Code (asks first) | — |
| `/debug [scroll\|fps\|log]` | — | Toggle debug overlays; listed only on debug builds | — |
| `/scroll-debug` | — | Toggle the scroll-diagnostics HUD; never listed | — |

### Keyboard

The defaults live in a single registry. Keys apply where the **Where** column says: *prompt* when the composer has
focus, *scrollback* when the conversation has focus (`Tab` switches between them), *agent* anywhere on the agent
screen, and *global* everywhere. `Ctrl+.` (or `Ctrl+X`) opens the full cheatsheet.

#### Essentials

| Keys | Where | Action |
|---|---|---|
| `Enter` | prompt | Send |
| `Ctrl+Enter` (alt `Ctrl+I`) | prompt | Send now while a turn runs: cancels the current turn and sends the message next. In Apple Terminal it is `Ctrl+O`; in VS Code-family terminals, `Ctrl+L` |
| `Tab` | prompt / scrollback | Focus the scrollback / focus the prompt (`i` or `Space` also focus the prompt) |
| `Ctrl+C` | agent | Cancel the turn |
| `Shift+Tab` | prompt | Cycle the mode ring ([Modes](#modes-shifttab-ring)) |
| `Ctrl+O` | agent | Toggle always-approve |
| `Ctrl+P` (alt `?`) | agent | Command palette: search every action and slash command by name |
| `Ctrl+.` (alt `Ctrl+X`) | agent | Keyboard shortcuts. Where `Ctrl+.` is unreliable (Windows, WSL, some terminals) the two keys swap |
| `Ctrl+M` | agent | Pick a model (when the prompt has focus, `Ctrl+M` toggles multiline instead) |
| `F2` (alt `Ctrl+,`, `Cmd+,`) | agent | Open the settings modal |
| `Ctrl+N` | global | New session |
| `Ctrl+Q` (alt `Ctrl+D`) | global | Quit; press twice in quick succession. In VS Code's terminal, `Ctrl+D` only |

#### Prompt

| Keys | Where | Action |
|---|---|---|
| `Ctrl+M` | prompt | Toggle multiline |
| `Ctrl+S` (alt `Alt+S`) | prompt | Stash the draft; on an empty composer, pop it back. A new stash replaces the old one |
| `!` | prompt | Shell mode (type it on an empty prompt) |
| `Up` | prompt | Browse prompt history (empty prompt). `/history` opens the search panel |
| `Esc` `Esc` | prompt | With text and no turn running: clear the prompt (`Ctrl+Z` restores it) |
| `Esc` `Esc` | prompt / scrollback | With an empty prompt and at least one turn: open the rewind picker |
| `Esc` | while a turn runs | Never cancels; a toast names the cancel key |

The double-press window is 800 ms (`OC_PAGER_ESC_DOUBLE_PRESS_MS` overrides it).

#### Panes and sessions

| Keys | Where | Action |
|---|---|---|
| `Ctrl+T` | agent | Toggle the todo pane |
| `Ctrl+G` | agent | Toggle the tasks pane. In minimal mode, edit the prompt in `$VISUAL` / `$EDITOR` (`vi` if neither is set) |
| `Ctrl+B` | agent | Send the running task to the background |
| `Ctrl+;` (alt `Ctrl+'`) | agent | Toggle the prompt queue. In VS Code-family terminals on a local Mac, `Ctrl+4` |
| `Ctrl+R` | agent | Open sessions |
| `Ctrl+L` | agent | Open extensions (unbound in VS Code-family terminals; use `/plugins`) |
| `Ctrl+\` (alt `Ctrl+4`) | global | Open the Agent Dashboard |

#### Scrollback

Bare-letter keys in this table work only when [`vim_mode`](#settings-configtoml) is on (`/vim-mode`). Arrows,
`PgUp`/`PgDn`, `Enter`, `Tab`, `?` and every `Ctrl` chord always work.

| Keys | Action |
|---|---|
| `j` / `k` (`Down` / `Up`) | Select the next / previous entry |
| `L` / `H` (`Shift+Right` / `Shift+Left`) | Next / previous turn |
| `J` / `K` | Jump to the next / previous turn at the top of the viewport |
| `g` / `G` | Go to the top / bottom |
| `Ctrl+K` / `Ctrl+J` | Scroll up / down one line |
| `Ctrl+U` / `Ctrl+D` | Scroll up / down half a page (in VS Code, `D` scrolls down) |
| `PgUp` / `PgDn` | Scroll one page |
| `h` / `l` (`Left` / `Right`) | Collapse / expand the selected entry |
| `e` / `E` | Expand or collapse the entry / every entry |
| `Ctrl+E` | Toggle all thinking blocks |
| `r` | Toggle raw markdown |
| `y` / `Y` | Copy the content / the command or path |
| `Enter` (alt `Ctrl+F`) | Open the entry in the viewer |
| `o` / `O` | Next / previous link |
| `x` | Kill the selected background task |
| `Ctrl+R` | Toggle mouse reporting, only when `[ui] mouse_reporting_toggle` is on (it then replaces Open sessions while the scrollback has focus) |

#### Agent Dashboard

| Keys | Action |
|---|---|
| `Down` / `Up` (`j` / `k` with vim mode) | Select the next / previous row |
| `Shift+Down` / `Shift+Up` | Move the agent down / up |
| `Shift+Tab` | Cycle the dispatch mode |
| `Ctrl+T` | Pin / unpin the agent |
| `Ctrl+R` | Rename the agent |
| `Ctrl+X` | Stop / delete the agent |
| `Ctrl+O` | Toggle always-approve for the selected agent |
| `Ctrl+G` | Toggle row grouping |
| `Ctrl+L` | Change the working directory for new agents |
| `Ctrl+W` | Toggle worktree mode for new agents |
| `Ctrl+.` (or `?`) | Show the shortcuts overlay |
| `Esc` | Close the dashboard |
| `Ctrl+\` | (inside an attached session) Back to the dashboard |
| `Ctrl+[` / `Ctrl+]` | (inside an attached session) Previous / next session |
| `Ctrl+X` | (inside an attached session) Stop the agent and go back to the dashboard |

#### Mouse

Click a pane to focus it. The wheel and trackpad scroll the pane (see the `scroll_*` settings), and you can click or
drag the scrollbar. In-app selection and double-click follow [`keep_text_selection`](#settings-configtoml); hold
`Shift` while dragging to use the terminal's own selection. Queued prompts have `[Interject]`, `[edit]` and `[cancel]`
buttons. Double-clicking a `[Pasted: N lines]` chip expands it into editable text.

### Modes (Shift+Tab ring)

`Shift+Tab` cycles **Build → Auto → Plan → Ask → Debug → Build**. Build, Plan, Ask and Debug are the engine's agents;
Auto is a permission stop that keeps the Build agent and sets the `auto` permission mode. The prompt border shows the
current mode in its color:

| Mode | Border |
|---|---|
| `build` | blue |
| `auto` | yellow |
| `plan` | violet |
| `ask` | green |
| `debug` | orange |
| `always-approve` | red |

The ring skips the Auto stop while always-approve is on, or when auto mode is not available. Leaving Auto writes
`permission_mode = "ask"` back. What each permission mode allows is in
[19 · Shift+Tab ring and permission modes](#shifttab-ring-and-permission-modes).

### Settings (`config.toml`)

The settings modal (`/settings`, `F2`) edits these settings. Unless the **Key** column gives another table, each is
written to the `[ui]` table of `<pager home>/config.toml` (`$OC_PAGER_HOME`). *Restart* means the change takes effect
on the next start. Rows in the modal's *Hidden in minimal* set still apply to the full TUI.

#### Appearance

| Key | Type | Default | Meaning |
|---|---|---|---|
| `theme` | enum | `oceannight` | Color theme ([Themes](#themes)) |
| `auto_dark_theme` | enum | `oceannight` | Theme used in system dark mode (only with `theme = "auto"`) |
| `auto_light_theme` | enum | `oceanday` | Theme used in system light mode (only with `theme = "auto"`) |
| `screen_mode` | `fullscreen` \| `minimal` | `fullscreen` | How plain `ocean-pager` opens next time. Restart. `/minimal` and `/fullscreen` switch only the current session |
| `compact_mode` | bool | `false` | Less padding around messages. Turns on automatically at 20 terminal rows or fewer |
| `show_timestamps` | bool | `true` | Clock time next to user messages and agent responses |
| `show_timeline` | bool | `false` | A per-turn tick rail in place of the scrollbar; hover to preview, click to jump |
| `page_flip_on_send` | bool | `true` | Scroll a sent prompt to the top of the screen so the response starts on a fresh page |
| `simple_mode` | bool | `true` | Plain readline-style input in the prompt instead of vim keys (vim input is experimental) |
| `vim_mode` | bool | `false` | Vim keys for navigating the scrollback; the prompt is unaffected |
| `render_mermaid` | `auto` \| `on` \| `off` | `auto` | `auto`/`on` show a clickable row that opens the rendered diagram; `off` shows the raw source |
| `max_thoughts_width` | int 40–500 | `120` | Column width of the thoughts panel |
| `show_thinking_blocks` | bool | `true` | Show thinking blocks while streaming |
| `group_tool_verbs` | bool | `true` | Fold consecutive read/search/list tool calls and subagent rows into one summary row |
| `collapsed_edit_blocks` | bool | `false` | Show edits as one-line `+N/-M` summaries and merge back-to-back edits to the same file |
| `[ui.display_refresh] auto_cadence_enabled` | bool | `true` | Match the display's refresh rate when streaming and scrolling; off keeps about 60 Hz. Restart |
| `respect_manual_folds` | bool | `false` | Keep manually folded blocks as they are while streaming (experimental). Written to `pager.toml` under `[scrollback.scroll]`, not `config.toml` |

#### Mouse

| Key | Type | Default | Meaning |
|---|---|---|---|
| `scroll_speed` | int 1–100 | `50` | Wheel and trackpad speed multiplier |
| `scroll_mode` | `auto` \| `wheel` \| `trackpad` | `auto` | Force wheel or trackpad behavior when detection misreads your device |
| `scroll_lines` | int 1–10 | `3` | Lines per scroll tick; until you set it, each terminal's own profile applies |
| `invert_scroll` | bool | `false` | Reverse the vertical scroll direction |
| `keep_text_selection` | `flash` \| `hold` \| `word_select` | `flash` | `flash` clears the selection after mouse-up, `hold` keeps it until `Esc`, a click or a scroll, and `word_select` makes a double-click select a word and a triple-click a paragraph |

#### Editor, agent and models

| Key | Type | Default | Meaning |
|---|---|---|---|
| `combine_queued_prompts` | bool | `false` | Merge consecutive plain follow-ups into one model turn |
| `confirm_before_rewind` | bool | `true` | Ask before rewinding the conversation |
| `permission_mode` | `default` \| `ask` \| `auto` \| `always-approve` | `ask` | The permission mode; the `Shift+Tab` ring and `Ctrl+O` write it too |
| `remember_tool_approvals` | bool | `true` | Offer "Always allow" options on permission prompts. Restart |
| `default_selected_permission` | `always_allow_all_sessions` \| `allow_command_always` \| `allow_once` \| `reject` | `always_allow_all_sessions` | The option the cursor starts on in a permission prompt |
| `[toolset.ask_user_question] timeout_enabled` | bool | `true` | The `ask_user_question` tool times out instead of waiting forever. Restart |
| `[models] default` | model | (none) | The model for new sessions; changing it also switches the active session |
| `fork_secondary_model` | model | (none) | The model for the secondary agent when you fork; clearing it restores the built-in default |
| `hunk_tracker_mode` | `off` \| `agent_only` \| `all_dirty` | `off` | Which file changes are tracked as hunks; `off` also turns off line-count stats. Restart |
| `[cli] show_tips` | bool | `true` | The tip-of-the-day banner on startup. Restart |
| `[cli] auto_update` | bool | `true` | Download pager updates on startup. Restart. When `oceancode` launches the pager, auto-update is always off ([19](#ocean-pager--the-terminal-ui)) |

`[ui.contextual_hints]` holds the per-hint switches. All default to `true`, and the modal shows them as one
*Show contextual hints* group: `undo` (`Ctrl+Z` restores a cleared prompt), `plan_mode` (suggests plan mode for a
planning request), `image_input` (offers to paste a clipboard image), `send_now` (empty-prompt `Enter` sends the top
queued item), `small_screen` (suggests `/compact-mode`), `word_select`, `export_copy` (points out `/copy` and
`/export`) and `ssh_wrap` (a `/doctor` tip over SSH).

Two modal rows are not saved. `multiline_mode` (default `false`) resets each session, and `plan_mode` (`off`/`on`,
default `off`) is per session.

#### Keys outside the modal

| Key | Type | Default | Meaning |
|---|---|---|---|
| `[ui] require_plan_approval` | bool | `false` | The plan viewer always asks before leaving plan mode |
| `[ui] mouse_reporting_toggle` | bool | `false` | Bind `Ctrl+R` in the scrollback to toggle mouse reporting (`OC_PAGER_MOUSE_REPORTING_TOGGLE` overrides it) |
| `[cli] session_picker_grouped` | bool | on | Group the session picker |
| `[dashboard] enabled` | bool | `true` | Enable `/dashboard` (`OC_PAGER_AGENT_DASHBOARD=0` turns it off) |

### Themes

| Name | Display name | Notes |
|---|---|---|
| `oceannight` | Ocean Night | Default. A neutral dark theme with a magenta accent. Aliases `ocean-night`, `dark` |
| `oceanday` | Ocean Day | A light theme. Aliases `ocean-day`, `light`, `day` |
| `tokyonight` | Tokyo Night | Needs truecolor. Aliases `tokyo-night`, `tokyo` |
| `rosepine-moon` | Rose Pine Moon | Needs truecolor. Aliases `rosepine`, `rose-pine`, `rose-pine-moon` |
| `oscura-midnight` | Oscura Midnight | Needs truecolor. Alias `oscura` |
| `terminal` | Terminal | Uses the terminal's own background and text colors. Offered only when the terminal theme is enabled (`OC_PAGER_TERMINAL_THEME`). Aliases `terminal-default`, `transparent`, `native` |
| `auto` | Auto | Follows the system's dark/light appearance and picks `auto_dark_theme` or `auto_light_theme`. Alias `system` |

Names are case-insensitive. On a terminal without truecolor, the themes that need it are not offered. The pre-rename
keys `groknight`, `grok-night`, `grokday` and `grok-day` still load, for one release. `OC_PAGER_THEME` forces a theme
without editing the config.

### Notifications

Desktop notifications, the tab title and the progress indicator are set in `[ui.notifications]`:

| Key | Default | Meaning |
|---|---|---|
| `method` | `auto` | `auto`, `osc9`, `osc99`, `osc777`, `bel` or `none` |
| `condition` | `unfocused` | `unfocused` (only once the terminal has lost focus), `always` or `never` |
| `idle_threshold_secs` | `3` | How many seconds the terminal must be unfocused before a notification fires |
| `events` | `["turn_complete", "approval_required"]` | Also available: `session_ready`, `task_complete`, `agent_error` |
| `sleep_prevention` | `true` | Keep the machine from idle-sleeping during a turn (macOS IOKit assertion, Linux `systemd-inhibit`) |
| `progress_bar` | `true` | Show a progress indicator in the tab (OSC 9;4) |
| `session_recap` | `true` | Show an automatic recap when you come back after being away |
| `session_recap_threshold_secs` | `30` | How many seconds unfocused count as "away" for the automatic recap |
| `[ui.notifications.title] enabled` | `true` | Set the terminal/tab title to reflect the agent's state |
| `[ui.notifications.title] items` | `["action-required", "spinner", "activity", "session-name", "ocean"]` | Also available: `cwd`, `model`, `turn-timer` |

**Protocol selection (`method = "auto"`).** The pager picks the protocol from the terminal:

| Terminal | Protocol |
|---|---|
| iTerm2, WezTerm, Warp | OSC 9 |
| Kitty | OSC 99 |
| Ghostty, VTE, Terminator, foot | OSC 777 (titled "Ocean Code") |
| Zellij (any terminal), and every other terminal | BEL |

Under tmux the sequence is wrapped in DCS passthrough, so it reaches the outer terminal. Control characters are
stripped from the text first. The progress indicator is drawn only on Ghostty, WezTerm and iTerm2 3.6 or later.

**Custom commands.** Each `[[ui.notifications.hooks]]` entry runs `command` through `sh -c` for its `events`. The
command gets `OC_PAGER_EVENT`, `OC_PAGER_MESSAGE` and `OC_PAGER_SESSION_ID`. `only_unfocused` defaults to `true`, and
`timeout_secs` defaults to `10` (minimum 1); a hook that runs past its timeout is killed.

```toml
[[ui.notifications.hooks]]
command = "terminal-notifier -title 'Ocean Code' -message \"$OC_PAGER_MESSAGE\""
events = ["turn_complete", "approval_required"]
```

**`/notify`.** `/notify` toggles desktop notifications, and `/notify on` or `/notify off` sets them. Off sets
`method` to `none` for the running pager. On restores the method you had before, or `auto`. It does not write
`config.toml`.

### See also

- [Ocean pager](#ocean-pager--the-terminal-ui): the binary, the environment variables, and what is wired to the engine
- [Permissions and sandbox](#permissions-and-sandbox): what each permission mode allows
- [Session ledger and export](#session-ledger-and-export): the tideline behind `/tideline`
- [Plan mode and todos](#plan-mode-and-todos): `/plan` and the todo pane
- [Bounding a long run](#bounding-a-long-run): `/budget` and `/limits`

---

## HTTP API reference

`oceancode serve` runs the engine as a long-lived HTTP server, by default on `http://127.0.0.1:4096` (`--port` and `--host` change it; the default pager shape takes `--listen <host:port>`). Every route below is relative to that base URL. HTTP basic auth is off until `OC_SERVER_PASSWORD` is set; then it covers every route, SSE included, with the username from `OC_SERVER_USERNAME` (default `oceancode`). A non-loopback bind refuses to start without the password. Events stream over SSE: `/event` serves the v1 frames, `/api/event` and `/global/event` the v2 payload-wrapped envelope. Flags and variables: [Headless and server](#headless-and-server).

```bash
curl -u "oceancode:$OC_SERVER_PASSWORD" http://127.0.0.1:4096/api/health
```

### v2 — /api

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/api/agent` | `v2.agent.list` | built-in + config agents. |
| GET | `/api/command` | `v2.command.list` | — |
| PATCH | `/api/credential/{credentialID}` | `v2.credential.update` | (relabel). The contract declares 204. |
| DELETE | `/api/credential/{credentialID}` | `v2.credential.remove` | The contract declares 204. |
| GET | `/api/event` | — | The v2 `GlobalEvent` stream shared by `GET /api/event` (`v2.event.subscribe`) and `GET /global/event` (`global.event`). |
| GET | `/api/fs/find` | `v2.fs.find` | ranked recursive search. |
| GET | `/api/fs/list` | `v2.fs.list` | the direct children of a directory. |
| GET | `/api/fs/read/{*path}` | `v2.fs.read` | The wildcard path is the file to read; the body is the raw bytes under the detected mime. |
| GET | `/api/health` | `v2.health.get` | — |
| GET | `/api/integration` | `v2.integration.list` | — |
| GET | `/api/integration/attempt/{attemptID}` | `v2.integration.attempt.status` | An unknown attempt is a 404 `NotFoundError`. |
| DELETE | `/api/integration/attempt/{attemptID}` | `v2.integration.attempt.cancel` | The contract declares 204. |
| POST | `/api/integration/attempt/{attemptID}/complete` | `v2.integration.attempt.complete` | The contract declares 204. |
| GET | `/api/integration/{integrationID}` | `v2.integration.get` | — |
| POST | `/api/integration/{integrationID}/connect/key` | `v2.integration.connect.key` | The contract declares 204. |
| POST | `/api/integration/{integrationID}/connect/oauth` | `v2.integration.connect.oauth` | — |
| GET | `/api/location` | `v2.location.get` | The `LocationInfo` schema requires a resolved `{directory, project}` (project carries id + directory), so it is built via the shared helper rather than a bare `{directory}`. |
| GET | `/api/model` | `v2.model.list` | — |
| GET | `/api/permission/request` | — | the pending requests wrapped in `{location, data}`. |
| GET | `/api/permission/saved` | — | the always-allow rules as `PermissionSavedInfo`s wrapped in `{data}`. |
| DELETE | `/api/permission/saved/{id}` | — | drops a saved rule addressed by the composite `"<permission>:<pattern>"` id. |
| GET | `/api/provider` | `v2.provider.list` | — |
| GET | `/api/provider/{providerID}` | `v2.provider.get` | — |
| GET | `/api/pty` | `v2.pty.list` | — |
| POST | `/api/pty` | `v2.pty.create` | — |
| GET | `/api/pty/{ptyID}` | `v2.pty.get` | — |
| PUT | `/api/pty/{ptyID}` | `v2.pty.update` | — |
| DELETE | `/api/pty/{ptyID}` | `v2.pty.remove` | The v2 contract is 204. |
| GET | `/api/pty/{ptyID}/connect` | `v2.pty.connect` | (WebSocket upgrade). |
| POST | `/api/pty/{ptyID}/connect-token` | `v2.pty.connectToken` | — |
| GET | `/api/question/request` | — | the `{location, data}` envelope over the request list. |
| GET | `/api/reference` | `v2.reference.list` | — |
| GET | `/api/session` | `v2.session.list` | — |
| POST | `/api/session` | `v2.session.create` | — |
| GET | `/api/session/active` | `v2.session.active` | The sessions with an in-flight coordinator drain, keyed by id. |
| GET | `/api/session/{sessionID}` | `v2.session.get` | — |
| POST | `/api/session/{sessionID}/agent` | `v2.session.switchAgent` | Publishes the durable agent-switched event the projector already handles. |
| POST | `/api/session/{sessionID}/compact` | `v2.session.compact` | Triggers a compaction turn via `Runner::compact` (the same summarize-then-replace path the runner's own overflow recovery uses). 204 on success; errors map through the shared session-error writer. |
| GET | `/api/session/{sessionID}/context` | `v2.session.context` | The active post-compaction context window, wrapped in `{data:[…]}`. |
| POST | `/api/session/{sessionID}/interrupt` | `v2.session.interrupt` | the v2 alias of the v1 `session.abort` semantics. |
| GET | `/api/session/{sessionID}/message` | `v2.session.messages` | — |
| GET | `/api/session/{sessionID}/message/{messageID}` | `v2.session.message` | — |
| POST | `/api/session/{sessionID}/model` | `v2.session.switchModel` | — |
| GET | `/api/session/{sessionID}/permission` | — | the pending requests scoped to one session. |
| POST | `/api/session/{sessionID}/permission` | — | evaluates the requested action/resources against the session's ruleset synchronously (no blocking wait) and returns `{id, effect}`, mirroring the v2 contract's non-interactive evaluation shape. |
| GET | `/api/session/{sessionID}/permission/{requestID}` | — | a single pending request. |
| POST | `/api/session/{sessionID}/permission/{requestID}/reply` | — | — |
| GET | `/api/session/{sessionID}/permission/mode` | — | the session's mode + lockout. |
| POST | `/api/session/{sessionID}/permission/mode` | — | sets the mode from `{ "mode": "acceptEdits" }`; 400 on an unknown mode, 409 when bypass is locked. |
| POST | `/api/session/{sessionID}/prompt` | `v2.session.prompt` | Admits durable user input (the `session.next.prompted` event) then drives the coordinator to run the turn. The drain runs `Runner::run`, which streams the provider turn and projects the assistant message. |
| GET | `/api/session/{sessionID}/question` | — | — |
| POST | `/api/session/{sessionID}/question/{requestID}/reject` | — | TS validates the path params BEFORE the handler resolves the session, so a malformed `requestID` is a 400 even for a session that does not exist. |
| POST | `/api/session/{sessionID}/question/{requestID}/reply` | — | — |
| POST | `/api/session/{sessionID}/revert/clear` | — | the v2 alias of `Store::unrevert`. |
| POST | `/api/session/{sessionID}/revert/commit` | — | The ported store has no durable "committed" state distinct from "staged" (unlike upstream's stage/commit split, which also touches the working tree), so commit is a no-op success once a bookmark is confirmed to exist — staging already persisted the revert. |
| POST | `/api/session/{sessionID}/revert/stage` | — | Faithful to the v1 revert semantics (`Store::revert` stages a bookmark at `messageID`); `files` (a diff/file-list request) is accepted but the ported `RevertState` carries no diff/files projection, so those fields are omitted rather than fabricated. |
| POST | `/api/session/{sessionID}/wait` | `v2.session.wait` | TS's `V2Session.wait` is a stub: after confirming the session exists it unconditionally fails with `Session.OperationUnavailableError`, mapped to a 503 `ServiceUnavailableError`. Match the status and message exactly so a client cannot tell the servers apart — the existence check still produces 404 for an unknown id first. |
| POST | `/api/session/{sessionID}/subagent/{childID}/message` | — | Steers (`delivery:"steer"`) or queues (`"queue"`, the default) `text` into the child's live turn, or resumes it when it has settled. Returns the delivery status. A missing child is a 404. |
| POST | `/api/session/{sessionID}/subagent/{childID}/resume` | — | Re-enters a completed child with `text` on a fresh step budget and returns its new result. A missing child is a 404. |
| GET | `/api/skill` | `v2.skill.list` | — |

### v1 — auth / catalog / config

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/agent` | `app.agents` | the v1 shape. |
| PUT | `/auth/{providerID}` | `auth.set` | persist a credential to `auth.json`. |
| DELETE | `/auth/{providerID}` | `auth.remove` | drop the stored credential. |
| GET | `/command` | `command.list` | the v1 shape. |
| GET | `/config` | `config.get` | the merged config. |
| PATCH | `/config` | `config.update` | Deep-merges the body over the project config document and persists it. The merged value is written back into `Services` so the cached read path (`config.get`, `formatter.status`, agent/command discovery) reflects the change. TS `ConfigHttpApi.update` returns `ctx.payload` — the SUBMITTED patch, not the merged config — so the request body is echoed. |
| GET | `/config/providers` | `config.providers` | TS `config.providers` returns `providerSvc.list()` — the RESOLVED, connected providers (those with a usable credential via `auth.json`, a stored DB credential, or a declared env key) — plus a per-provider default-model map. It is NOT the bare `oceancode.json` `provider` override block: the TUI's connect gate iterates this list, and an override-only answer left the gate permanently false. |
| GET | `/event` | `event.subscribe` | the v1 SSE stream (`{id,type,properties}` frames). Mirrors `EventSubscribe`. |

### experimental

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/experimental/capabilities` | `experimental.capabilities.get` | Mirrors `experimental.ts capabilities()`: `{backgroundSubagents: …}`: on unless `OC_EXPERIMENTAL_BACKGROUND_SUBAGENTS` is `0`/`false` — the same rule the task tool's background mode reads. |
| GET | `/experimental/console` | `experimental.console.get` | Mirrors `experimental.ts getConsole()`: `{consoleManagedProviders, activeOrgName?, switchableOrgCount}`. With no cloud accounts registered `account.orgsByAccount()` returns `[]` (`account.ts:329` over an empty `repo.list()`), so `switchableOrgCount` is 0; `consoleManagedProviders` is `[]` (`config.ts:589`); `activeOrgName` is omitted. No 500 — the empty result is a success. |
| GET | `/experimental/console/orgs` | `experimental.console.listOrgs` | Mirrors `experimental.ts listConsoleOrgs()`: `{orgs: [...]}`. With no accounts, `orgsByAccount()` is `[]` so the flattened list is empty (`experimental.ts:56`). |
| POST | `/experimental/console/switch` | `experimental.console.switchOrg` | Mirrors `experimental.ts switchConsole()`: `account.use(accountID, orgID)` wrapped in a catch that maps any failure to a 400 `BadRequest` (`experimental.ts:84`). With no account registered `use()` fails, so this is the faithful 400. |
| POST | `/experimental/control-plane/move-session` | `experimental.controlPlane.moveSession` | Mirrors `control-plane.ts moveSession()`: 204 on success; failures map to `MoveSessionError`. `MoveSession` first loads the session (`move-session.ts:77`) — a missing one yields `"Session not found: {id}"` (`control-plane.ts:33`). The move itself is a local operation and is a no-op success when the destination is the session's current directory (`move-session.ts:79`). |
| POST | `/experimental/project/{projectID}/copy` | `v2.projectCopy.create` | — |
| DELETE | `/experimental/project/{projectID}/copy` | `v2.projectCopy.remove` | The contract declares 204. |
| POST | `/experimental/project/{projectID}/copy/generate-name` | `experimental.projectCopy.generateName` | The LLM naming path is not ported; a deterministic slug is returned, matching the `{name}` shape. |
| POST | `/experimental/project/{projectID}/copy/refresh` | `v2.projectCopy.refresh` | The contract declares 204. |
| GET | `/experimental/resource` | `experimental.resource.list` | Mirrors `experimental.ts resource()`: `mcp.resources() -> Record<id, Resource>`. The MCP manager bridges tools but does not enumerate resources, so a fresh instance returns the empty map (`experimental.ts:169`). |
| GET | `/experimental/session` | `experimental.session.list` | Mirrors `experimental.ts session()`: `sessions.listGlobal(...) -> GlobalSession[]`. The global cross-project aggregate (slug/summary/cost/tokens/project) is not ported; a fresh self-hosted instance's `listGlobal` yields `[]` (`experimental.ts:134`). |
| POST | `/experimental/session/{sessionID}/background` | `experimental.session.background` | Mirrors `experimental.ts sessionBackground()`: returns `false` immediately when the `experimentalBackgroundSubagents` runtime flag is off (`experimental.ts:156`). That flag is off by default. |
| GET | `/experimental/tool` | `tool.list` | every registered tool's id/description/parameters. |
| GET | `/experimental/tool/ids` | `tool.ids` | — |
| GET | `/experimental/workspace` | `experimental.workspace.list` | — |
| POST | `/experimental/workspace` | `experimental.workspace.create` | — |
| GET | `/experimental/workspace/adapter` | `experimental.workspace.adapter.list` | Mirrors `workspace.ts adapters() -> listAdapters(projectID)` (`workspace.ts:16`). The only built-in is the worktree adapter (`control-plane/adapters/index.ts BUILTIN`), reported with its name/description (`adapters/worktree.ts:29-30`). |
| GET | `/experimental/workspace/status` | `experimental.workspace.status` | Mirrors `workspace.ts status()`: `[...connections.values()]` filtered to the project's workspaces (`workspace.ts:55, 833`). With no active sync connections the map is empty. |
| POST | `/experimental/workspace/sync-list` | `experimental.workspace.syncList` | Mirrors `workspace.ts syncList()`: discover workspaces via the registered adapters and register any new ones, no body, 204 (`workspace.ts:51, 743`). The built-in worktree adapter discovers nothing without configured remotes, so this is a no-op 204. |
| POST | `/experimental/workspace/warp` | `experimental.workspace.warp` | Mirrors `workspace.ts warp()`: `workspace.sessionWarp(...)` where a missing workspace maps to a 404 `NotFoundError` (`workspace.ts:81`). With no synced workspaces the target is always absent. |
| DELETE | `/experimental/workspace/{id}` | `experimental.workspace.remove` | — |
| GET | `/experimental/worktree` | `worktree.list` | the copy worktree directories. |
| POST | `/experimental/worktree` | `worktree.create` | a linked worktree on a new branch under the repo of the request directory. |
| DELETE | `/experimental/worktree` | `worktree.remove` | INTENTIONAL, SAFER DIVERGENCE FROM TS, carried verbatim from Go: `git worktree remove` refuses to remove the repository's MAIN worktree and that refusal surfaces as a 400. The TS handler is permissive and reports 200 for the same request because its remove path does not guard the main worktree. The guard is kept — matching TS would mean performing (or claiming) a destructive removal of the primary checkout. The non-main path is identical (200) on all three servers. |
| POST | `/experimental/worktree/reset` | `worktree.reset` | hard-reset a linked worktree (the body's directory) back to the default branch of the repo identified by the request directory. |

### files / search

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/file` | `file.list` | direct children as v1 `FileNode`s. |
| GET | `/file/content` | `file.read` | The TS v1 `/file/content` returns only `{type, content}` (no mimeType) and trims the decoded text; both are mirrored so the envelope matches byte-for-byte. |
| GET | `/file/status` | `file.status` | the working-tree git status mapped onto the v1 `File` shape. |
| GET | `/find` | `find.text` | ripgrep-style file-content search rooted at the request directory. `pattern` is the regexp; the results are the ripgrep `match` records the TS handler forwards verbatim. |
| GET | `/find/file` | `find.files` | ranked file-name search returning the matching paths. |
| GET | `/find/symbol` | `find.symbols` | The LSP client exposes hover/definition/diagnostics but not `workspace/symbol`, and no live client is retained, so symbol search has no backend; the endpoint returns an empty-but-valid array. |
| GET | `/formatter` | `formatter.status` | the per-formatter enabled/extensions status computed from config. |

### global / instance

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/global/config` | `global.config.get` | — |
| PATCH | `/global/config` | `global.config.update` | TS `GlobalHttpApi.configUpdate` persists and returns only the submitted patch projection (an empty patch yields `{}` after provider/default stripping), not the full merged config. Echo the request body. |
| POST | `/global/dispose` | `global.dispose` | Mirrors `global.ts dispose()`: returns `true` after disposing. The server is a single long-lived process, so there is nothing to tear down per request (the OS process owns the lifecycle). |
| GET | `/global/event` | — | The v2 `GlobalEvent` stream shared by `GET /api/event` (`v2.event.subscribe`) and `GET /global/event` (`global.event`). |
| GET | `/global/health` | `global.health` | Mirrors `global.ts health()`: `{healthy: true, version}`, plus the four fields this port adds so every running instance says what it is: `model_format` is always `"markdown"` — the only payload format the product renders since D9; the field stays because clients read it, but it is no longer configurable — `ledger` says whether the graph's Session Ledger hook is installed (`ocean_app::Services::ledger`) — leaf T48b / T52b — `build` is the binary's own build stamp (`crate::server_build`, leaf T65), and `dropped_events` is the bus's drop-oldest counter (`ocean_events::Bus::dropped_events`, long-runs plan Task 13 / L-12): live events evicted from a saturated subscriber queue since boot — a saturation signal, not an error count, and never reset. |
| POST | `/global/upgrade` | `global.upgrade` | Mirrors `global.ts upgrade()`: when the installation method is `"unknown"` it reports `{success:false, error:"Unknown installation method"}`. This build is not produced by a known installer. The contract models this as the failure variant of the 200 `anyOf` (the 400 is reserved for the BadRequest envelope), so it is returned with status 200. |
| POST | `/instance/dispose` | `instance.dispose` | Mirrors `instance.ts dispose()`: returns `true`. Single-process, so no per-request teardown. |
| POST | `/log` | `app.log` | write the structured log line then return true. |

### lsp / mcp

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/lsp` | `lsp.status` | The registry resolves candidate servers but does not hold live clients (spawn is on demand), so there is no connected server to report; the endpoint returns an empty-but-valid array. |
| GET | `/mcp` | `mcp.status` | a map of server name -> status. |
| POST | `/mcp` | `mcp.add` | register a server at runtime and connect it, returning the full status snapshot. |
| POST | `/mcp/{name}/auth` | `mcp.auth.start` | begin the remote OAuth flow, returning the authorization URL the caller opens. |
| DELETE | `/mcp/{name}/auth` | `mcp.auth.remove` | The 200 body is `{"success": true}` (`additionalProperties:false`), not a bare boolean. |
| POST | `/mcp/{name}/auth/authenticate` | `mcp.auth.authenticate` | — |
| POST | `/mcp/{name}/auth/callback` | `mcp.auth.callback` | completes the remote OAuth flow with the code returned to the loopback callback and answers with the server's resulting status. |
| POST | `/mcp/{name}/connect` | `mcp.connect` | — |
| POST | `/mcp/{name}/disconnect` | `mcp.disconnect` | — |
| GET | `/mcp/{name}/prompts` | — | the server's cached prompt list (P1-T38). |
| GET | `/mcp/{name}/resources` | — | the server's advertised resources (P1-T38), via `McpManager::list_resources` → `Client::list_resources`. |
| GET | `/mcp/{name}/tools` | — | the server's cached tool definitions (WP6d). |
| POST | `/mcp/{name}/trust` | `mcp.trust` | (GAP-06): persist the per-server trust decision to `<home>/.oceancode/trust.json` (keyed by the project dir) and apply it to the running manager, returning the refreshed `GET /mcp` map. |
| POST | `/mcp/{name}/enable` | `mcp.enable` | (GAP-06 parity): persist the enable/disable decision to `<home>/.oceancode/trust.json`'s `disabled_mcp` (so the next boot honours it) and apply it live to the running manager, returning the refreshed `GET /mcp` map. An unknown server id is a 404. |

### hooks (GAP-06)

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/hooks` | — | the boot-time hook source report plus this project's repo trust bit (GAP-06). Read-only: the rows are whatever the boot loader (or the last reload) recorded. |
| POST | `/hooks` | — | add a command hook to one of Claude Code's three writable settings files (GAP-06 parity), then reload the dispatcher and return the refreshed `GET /hooks` shape. An unknown event / empty command is a 400 (`InvalidInput`); a settings file that is not a JSON object is a 400 (`InvalidData`, never clobbered). The body is an `ocean_app::hooks_boot::HookEdit`. |
| POST | `/hooks/reload` | — | re-run the boot hook loader (re-attaching the dispatcher) and return the refreshed report (GAP-06). |
| POST | `/hooks/remove` | — | remove a hook from one of the three writable settings files (GAP-06 parity), then reload and return the refreshed `GET /hooks` shape. A path that is not one of the three writable scopes is a 400 (`InvalidInput`); a handler that is not present is a 404 (`NotFound`). The body is an `ocean_app::hooks_boot::HookRemove`. |
| POST | `/trust/repo` | — | flip this project's repo-tier trust (GAP-06): persist the decision, release/hold the repo's `.mcp.json` servers and reload the hook dispatcher, then return the refreshed `GET /hooks` shape. A trust-store write error is a 400. |

### the Claude Code settings mirror (2026-09-10)

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/claude/settings` | — | the Claude Code settings mirror (`model`, `effortLevel`/`modelSettings`, `ultracode`, `alwaysThinkingEnabled` of the three tiered `settings.json` files) with the effective effort and the model it pins (`ocean_app::claude_settings`, 2026-09-10). Read-only: the rows are whatever boot or the last watcher reload recorded. |

### path / permission

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/path` | `path.get` | The home/state/config values mirror the TS server's `Global.Path.*`, derived via the xdg-basedir port in `ocean-config` so they are byte-identical to the TS server on the same host/env (e.g. `~/.config/oceancode`, not the macOS `os.UserConfigDir()` location). |
| GET | `/permission` | — | the v1 alias, a bare array. |
| POST | `/permission/{requestID}/reply` | — | the v1 alias, dispatched on `requestID` alone. |

### plugin

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/plugin` | — | the installed plugins with their enable/trust bits. |
| POST | `/plugin/install` | — | install from `source`, returning `{name, version, enabled, trusted}`. |
| GET | `/plugin/marketplace` | — | the added marketplaces with their plugin entries. |
| POST | `/plugin/marketplace/add` | — | add a marketplace by spec. |
| DELETE | `/plugin/marketplace/{name}` | — | remove a marketplace, returning the refreshed marketplace list (GAP-06 parity). A marketplace that is not present is a 404. |
| POST | `/plugin/{name}/enable` | — | turn a plugin's loading on or off. |
| POST | `/plugin/{name}/trust` | — | grant or revoke a plugin's execution trust. A missing/invalid body is a 400 (`falsify_trust_route_requires_body`), so trust is never granted by an empty request. |
| GET | `/plugin/{name}` | — | the full detail of one installed plugin (GAP-06 parity). A name that is not installed is a 404. |
| DELETE | `/plugin/{name}` | — | uninstall a plugin, returning the refreshed installed list (GAP-06 parity). A plugin that is not installed is a 404. |

### project

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/project` | `project.list` | — |
| GET | `/project/current` | `project.current` | resolve the request directory. |
| POST | `/project/git/init` | `project.initGit` | initialize a repository in the request directory (idempotent). |
| PATCH | `/project/{projectID}` | `project.update` | — |
| GET | `/project/{projectID}/directories` | `project.directories` | — |

### provider

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/provider` | `provider.list` | `{all, default, connected}`. |
| GET | `/provider/auth` | `provider.auth` | Mirrors `provider.ts auth()`: `svc.methods() -> Record<providerID, Method[]>`. No v1 auth-method plugins are ported, so this is the empty map a fresh instance returns. |
| POST | `/provider/{providerID}/oauth/authorize` | `provider.oauth.authorize` | Mirrors `provider.ts authorize()`: with no oauth method registered for the provider the TS service hits an undefined method lookup, which the handler maps to a generic `BadRequest` `ProviderAuthError`. The Claude Pro/Max subscription login is served elsewhere (the v2 integration endpoints and the `oceancode login` CLI), so a v1 client is never offered a method here. |
| POST | `/provider/{providerID}/oauth/callback` | `provider.oauth.callback` | Mirrors `provider.ts callback()`: with no pending oauth result the TS service returns `OauthMissing`. |

### pty

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/pty` | `pty.list` | — |
| POST | `/pty` | `pty.create` | — |
| GET | `/pty/shells` | `pty.shells` | the available login shells. |
| GET | `/pty/{ptyID}` | `pty.get` | — |
| PUT | `/pty/{ptyID}` | `pty.update` | — |
| DELETE | `/pty/{ptyID}` | `pty.remove` | The v1 contract answers 200 `true` (unlike the v2 delete, which is 204). |
| GET | `/pty/{ptyID}/connect` | `pty.connect` | (WebSocket upgrade). |
| POST | `/pty/{ptyID}/connect-token` | `pty.connectToken` | — |

### question

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/question` | — | every pending question across sessions. |
| POST | `/question/{requestID}/reject` | — | dismisses a pending question, failing the waiting `Ask` with a rejection. |
| POST | `/question/{requestID}/reply` | — | resolves a pending question with the user's answers, unblocking the waiting `Ask`. |

### session (v1)

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/session` | `session.list` | The v1 response is the bare array. |
| POST | `/session` | `session.create` | Maps the supported v1 body fields onto `SessionStore::create`; `parentID`, `metadata` and `permission` have no ported column and are dropped. |
| GET | `/session/status` | — | the run status of every session, keyed by id. |
| GET | `/session/{sessionID}` | `session.get` | — |
| DELETE | `/session/{sessionID}` | `session.delete` | Removes the session, its children, message projection and event log. |
| PATCH | `/session/{sessionID}` | `session.update` | the v1 title patch. |
| POST | `/session/{sessionID}/abort` | `session.abort` | The TS handler is idempotent: it interrupts any running turn and returns `true` even for an unknown session id (no 404 pre-check). Mirrored. The interrupt goes through the runner, not the bare coordinator, so it also reaches the session's sub-agents — foreground through the token cascade, background through the runner's job index (long-runs plan Task 12, L-9). |
| GET | `/session/{sessionID}/children` | — | the sub-sessions whose `parentID` is this session. The store has no parent filter, so this lists and filters in-process (the TS handler likewise scans). |
| POST | `/session/{sessionID}/command` | — | resolves a registered command, substitutes its arguments, and runs it as a prompt turn. An unknown command maps to `CommandNotFoundError` (404). |
| GET | `/session/{sessionID}/diff` | `session.diff` | the structured diff for the session's working directory (git mode). |
| POST | `/session/{sessionID}/fork` | — | copies the session into a new one (optionally truncated at `messageID`). |
| POST | `/session/{sessionID}/init` | — | runs the project-analysis (`/init`) flow as a prompt turn. |
| GET | `/session/{sessionID}/checkpoints` | — | the recorded checkpoints (id, reason, message bookmark, seq), oldest first. An unknown session lists nothing. |
| POST | `/session/{sessionID}/undo` | — | restore the last pre-write state. With no earlier checkpoint it is a no-op with a `nothing to undo` body, never an error. |
| POST | `/session/{sessionID}/redo` | — | re-apply the next checkpoint's files. Available only after an undo; otherwise a no-op with a `nothing to redo` body. |
| GET | `/session/{sessionID}/checkpoints/{a}/diff/{b}` | — | a unified diff between two checkpoints (text/plain); the header names both short ids. An unknown session or checkpoint is a 404. |
| POST | `/session/{sessionID}/checkpoints/revert-hunk` | — | restore one diff hunk of `file` to its pre-edit (checkpoint) bytes, leaving every other hunk. An unknown hunk id is a 404 (never a whole-file overwrite). |
| POST | `/session/{sessionID}/checkpoints/apply-hunk` | — | re-apply one previously reverted hunk of `file`. |
| GET | `/session/{sessionID}/message` | `session.messages` | the v1 alias. |
| POST | `/session/{sessionID}/message` | `session.prompt` | the v1 prompt alias. |
| GET | `/session/{sessionID}/message/{messageID}` | `session.message` | — |
| DELETE | `/session/{sessionID}/message/{messageID}` | `session.deleteMessage` | — |
| DELETE | `/session/{sessionID}/message/{messageID}/part/{partID}` | `part.delete` | — |
| PATCH | `/session/{sessionID}/message/{messageID}/part/{partID}` | `part.update` | The body is a v1 `Part` (the full tagged-union shape). The flat ported model stores assistant content (text/reasoning/tool) inline rather than as addressable Part rows, so for those types the update is applied to the inline content; for every type the updated Part is returned with the path-derived ids, matching the v1 contract. |
| POST | `/session/{sessionID}/permissions/{permissionID}` | — | the v1 session-scoped response. The `{response}` enum maps directly onto `permission::Reply`. |
| POST | `/session/{sessionID}/prompt_async` | — | admits durably and returns immediately; the turn runs in the background. |
| POST | `/session/{sessionID}/revert` | — | records a revert bookmark at the given message/part. |
| POST | `/session/{sessionID}/share` | — | mints (or returns) the share row. |
| DELETE | `/session/{sessionID}/share` | — | drops it. |
| POST | `/session/{sessionID}/shell` | — | runs a shell command in the session's working directory, recording it as a durable shell message. |
| GET | `/session/{sessionID}/subagent` | — | the session's effective sub-agent model (2026-09-16), so a remote TUI shows the identity row's `sub-agents` segment. The engine owns the whole precedence (`ocean_app::Services::effective_subagent_model`); this hands its answer back as `{"model": ModelRef \| null}`. An unknown session is 404, like the sibling per-session GETs. |
| POST | `/session/{sessionID}/summarize` | — | runs a summarization turn and reports whether a summary was produced. |
| GET | `/session/{sessionID}/todo` | `session.todo` | The `ETag` header is the table's entity tag; send it back as `If-Match` on `PUT` for a conditional edit. |
| PUT | `/session/{sessionID}/todo` | — | the user's edit. Rows are matched by id or content; changed and new rows become user-authored; rows left out are removed. With `If-Match` set to a stale tag nothing is written and the answer is `412` carrying the current rows and tag. |
| POST | `/session/{sessionID}/unrevert` | — | clears the revert bookmark. |
| GET | `/skill` | `app.skills` | the v1 bare array. |

### sync

| Method | Path | Operation | What it does |
|---|---|---|---|
| POST | `/sync/history` | `sync.history.list` | Mirrors `sync.ts history()`: select every durable event from the `event` table, excluding the `(aggregateID, seq)` cursors in the payload, ordered by seq (`sync.ts:72`). The payload maps `aggregateID -> lastSeq` the caller already has. |
| POST | `/sync/replay` | `sync.replay` | Mirrors `sync.ts replay()`: replay the serialized events into the event store, return `{sessionID: events[0].aggregateID}` (`sync.ts:34`). TS's `ReplayPayload` declares `directory: Schema.String` and `events: Schema.NonEmptyArray(ReplayEvent)`, so the HttpApi rejects a missing directory or an empty array with a 400 BEFORE the handler runs. |
| POST | `/sync/start` | `sync.start` | Mirrors `sync.ts start()`: fork `startWorkspaceSyncing` with `Effect.ignore`, return `true` (`sync.ts:27`). The fork is best-effort and its result is ignored, so the response is unconditionally `true`. |
| POST | `/sync/steal` | `sync.steal` | Mirrors `sync.ts steal()`: requires the instance `workspaceID`; without one it returns 400 `BadRequest` (`sync.ts:62`). A self-hosted instance has no workspace binding. The contract's 400 is the effect `HttpApiError BadRequest` (`{_tag:"BadRequest"}`), not the `{name,data}` envelope. |

### tui

| Method | Path | Operation | What it does |
|---|---|---|---|
| POST | `/tui/append-prompt` | `tui.appendPrompt` | — |
| POST | `/tui/clear-prompt` | `tui.clearPrompt` | — |
| GET | `/tui/control/next` | `tui.control.next` | long-poll for the next queued TUI request (FIFO). Parks until a request is enqueued or the client disconnects. |
| POST | `/tui/control/response` | `tui.control.response` | enqueue the TUI's reply for whoever is waiting (fire-and-forget FIFO). |
| POST | `/tui/execute-command` | `tui.executeCommand` | — |
| POST | `/tui/open-help` | `tui.openHelp` | — |
| POST | `/tui/open-models` | `tui.openModels` | — |
| POST | `/tui/open-sessions` | `tui.openSessions` | — |
| POST | `/tui/open-themes` | `tui.openThemes` | — |
| POST | `/tui/publish` | `tui.publish` | dispatch a TUI event onto the request queue for a connected client to pull via `control/next`. |
| POST | `/tui/select-session` | `tui.selectSession` | — |
| POST | `/tui/show-toast` | `tui.showToast` | — |
| POST | `/tui/submit-prompt` | `tui.submitPrompt` | — |

### vcs

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/vcs` | `vcs.get` | current/default branch info. |
| POST | `/vcs/apply` | `vcs.apply` | applies a unified patch to the working tree. The contract's 200 body is `{applied: boolean}`. |
| GET | `/vcs/diff` | `vcs.diff` | the structured per-file diff. |
| GET | `/vcs/diff/raw` | `vcs.diff.raw` | The contract's 200 body is a `text/x-diff` string (not JSON), so the diff is written verbatim with that content type. |
| GET | `/vcs/status` | `vcs.status` | the working-tree file status list. |

Generated from `crates/ocean-server/src/router.rs` — 218 operations.
