> ## Documentation Index
> Fetch the complete documentation index at: https://docs.obversa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Review a diff in a host pane

> obversa-review opens a git diff for inline review in a browser pane beside the terminal and returns the annotations to the caller.

`obversa-review` opens a git diff as a review page in a browser pane
beside the terminal. You read the diff, attach comments to lines, and
return them. The command writes the annotations to stdout as structured
output, so a script or an agent reads them without a screen.

## Use

The command reviews one of three diffs. Run it inside the repository, or
point it at one with `--cwd`.

```sh theme={null}
# Review the working tree (git diff).
obversa-review

# Review the staged changes (git diff --cached).
obversa-review --staged

# Review a ref range (git diff A..B).
obversa-review --range main..HEAD
```

The three are one choice: naming two modes on one command line (`--range
main..HEAD --staged`) is a usage error, whatever their order.

A diff that holds an unresolved merge conflict is refused whole with a
message naming the conflicted path. Git describes such a path as a
combined diff, which a review could only show as a missing file, so the
command never opens a page that looks clean over a conflicted tree.

The page is placed through the absolute path a host injects as
`OBVERSA_SURFACE_BIN` — the cmux host's workspace launcher binds it to its
`obversa-surface` glue for every terminal and agent in a workspace — and
with the variable unset the default browser opens the page; nothing is
looked up on `PATH`. The placement command receives a one-time launch URL
(single use, valid for a minute) that redirects to the page; the page URL
with its token never enters a process argument.

Other flags:

* `--cwd <dir>` runs against another repository directory.
* `--no-open` does not place the pane. The command prints the page URL
  on stderr, and you open it yourself.
* `--app <name>` overrides the surface app name. The name sets the frame
  markers on stdout (see below).

The working tree diff is `git diff`, so untracked files do not appear.

The diff, the code around each hunk, and the file list are captured
twice, and the review is accepted only when both captures agree on every
line, every file read, and the file list. If the repository changes
between them, the command tries the pair again — three attempts in all —
and then stops with `The repository changed while the review was being
captured; retry when it is quiet`. A change that is made and undone the
same way during both captures is not detected; the command takes no lock
on the repository.

In cmux the page opens in a split beside the terminal. Outside cmux it
opens in your default browser. [How placement works](/hosts/cmux).

## What the page offers

* The diff, with syntax highlighting by file type.
* A file tree with two views. **Changes** lists the changed files with
  file-type icons, `+`/`−` line counts, and a status letter. **All
  files** lists every tracked file in the repository. Click a changed
  file to scroll to its diff.
* Collapsed **N unmodified lines** bands between hunks. Click one to see
  the unchanged code around a hunk. **Expand full file** on a file header
  opens every band in that file. Full-file context is available in the
  working tree and staged modes, not in range mode.
* Go to definition for JavaScript files (`.js`, `.mjs`, `.cjs`). Click an
  identifier to jump to the line that defines it, when that line is in
  the diff. When the definition is not shown, the tooltip names its line
  number.
* A `+` button on every diff line. Click it to write a comment on that
  line. You can remove a comment before you return.
* **Return** ends the review with the comments you wrote, or with none.
  **Cancel** ends it with no comments and a `cancelled` decision.

## What comes back

The command writes one framed JSON object to stdout, so a caller captures
it with a pipe. The one-line summary goes to stderr. For the default app
name the markers are `<<<REVIEW_RESULT_V1>>>` and
`<<<END_REVIEW_RESULT_V1>>>`. The marker name is the app name in upper
case, with punctuation replaced by `_`.

The payload is the surface result: the one result shape every Obversa
review surface returns, whatever it shows. The `surface` field names the
package that answered and the version that was resolved, on every status,
so a consumer can always say which surface produced the frame.

```json theme={null}
{
  "schemaVersion": 1,
  "app": "review",
  "surface": { "package": "@obversa/source", "version": "0.1.0" },
  "status": "completed",
  "operationId": "…",
  "createdAt": "…",
  "payload": {
    "surfaceId": "6f1c…",
    "gateId": null,
    "decision": "changes-requested",
    "annotations": [
      {
        "anchor": { "target": "src/git.mjs", "side": "new", "position": 42 },
        "body": "Reject an empty range here too.",
        "author": { "kind": "human", "id": "reviewer" },
        "createdAt": "2026-01-01T00:00:00.000Z",
        "thread": [
          { "author": { "kind": "agent", "id": "fixer" }, "body": "Done: the empty range is refused with its own message." }
        ]
      }
    ],
    "meta": {
      "mode": "worktree",
      "range": null,
      "label": "working tree",
      "fileCount": 3,
      "allFiles": ["src/git.mjs", "…"]
    }
  },
  "detail": null
}
```

An annotation may carry a `thread`: an ordered list of replies, each an
author and a body, shaped and bounded like the annotation itself. A
responder agent appends to it with `appendToThread(annotation, entry)` from
`@obversa/source` — the call returns a new annotation and never mutates the
input — and a thread submitted with the review rides the framed result
intact, so the next round sees the conversation so far.

* `surfaceId` names this review session. `gateId` names the pipeline gate
  that opened it, or `null` when you ran the command directly.
* `decision` agrees with the annotations: `changes-requested` carries at
  least one, and `approved` carries none. A submission that says otherwise
  is refused and the page stays open, so a decision is never rewritten on
  the reviewer's behalf. `cancelled` and `timed-out` are set only by the
  session's own ending, never by a submission.
* Each annotation has an `anchor`: `target` is the repository-relative file
  path as it appears in the diff, `side` is `old` for a deleted line or `new`
  for an added or unchanged line, and `position` is the line number on that
  side. An anchor is those three plain values and nothing else; one with an
  extra, hidden, or computed field is refused. `body` is the comment text
  with surrounding whitespace removed. `author` is the reviewer, and
  `createdAt` is when the comment was written.

The bounds are fixed. A review returns at most 500 annotations. Each body
is cut at 4000 characters. An annotation must anchor to a line that
appears in the diff; the server drops any other.

`status` is `completed` when the reviewer returns. `cancelled`, `timed_out`,
and `interrupted` carry a short `detail` and a payload with the same
`surfaceId` and `gateId`, `decision` set to `cancelled` or `timed-out`, and
no annotations, so a gate can still route the outcome. The exit code is 0
for `completed`, 1 for every other status, and 2 for a bad argument.

## Security

The page runs on [the Surfacer](/packages/surfacer): one loopback server,
one session token, one result. The static page names nothing under review:
not the diff, not the ref, not even the highlight rules, which are built
from the tokens the diff contains. The page fetches the diff, its label, and
those rules from the session API with the bearer token, so a local process
that finds the port sees a shell that is the same for every review. The
address the page fetches from is a path on the session's own origin or an
absolute `http` or `https` URL; a path that would leave the origin once
parsed is refused. Nothing in a diff line can execute. The page has no
inline script, a strict Content Security Policy allows scripts and styles
from the session origin only, and every line is rendered as text. The
returned annotations are checked on the server, and one that does not
anchor to a real diff line is dropped. Comment bodies are trimmed and cut at
4000 characters, and are otherwise returned as written: the secret
redaction the runtime applies to other responses does not apply to them,
because a comment can quote code.
