Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions docs/HOW_IT_WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ if needed.

### What's Stored in a Session

| Data | Description |
| ------------- | ------------------------------------------------- |
| Messages | Prompts you've sent and their metadata |
| Events | Tool calls, token streams, status updates |
| Artifacts | PRs created, screenshots captured |
| Participants | Users who have joined the session |
| Sandbox state | Reference to the current sandbox and its snapshot |
| Data | Description |
| ------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Messages | Prompts you've sent and their metadata |
| Events | Tool calls, token streams, status updates |
| Artifacts | PRs created, screenshots captured |
| Participants | Users who have joined the session |
| Sandbox state | Reference to the current sandbox and its snapshot |
| Plans | Versioned markdown plans + approval status (`awaiting_approval`, `approved`, `rejected`) — see [PLAN_MODE.md](PLAN_MODE.md) |

Each session gets its own SQLite database in a Cloudflare Durable Object, ensuring isolation and
high performance even with hundreds of concurrent sessions.
Expand Down Expand Up @@ -186,13 +187,13 @@ When you create a session for a repo without an existing snapshot:
└─────────┘ └──────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └───────┘
│ │
▼ ▼
.openinspect/setup.sh .openinspect/start.sh
scripts/.openinspect/setup.sh scripts/.openinspect/start.sh
```

1. **Sandbox created**: Modal spins up a new container from the base image
2. **Git sync**: Clones your repository using GitHub App credentials
3. **Setup script**: Runs `.openinspect/setup.sh` for provisioning (if present)
4. **Start script**: Runs `.openinspect/start.sh` for runtime startup (if present)
3. **Setup script**: Runs `scripts/.openinspect/setup.sh` for provisioning (if present)
4. **Start script**: Runs `scripts/.openinspect/start.sh` for runtime startup (if present)
5. **Agent start**: OpenCode server starts and connects back to the control plane
6. **Ready**: Sandbox accepts prompts

Expand All @@ -209,7 +210,7 @@ When restoring from a previous snapshot:

1. **Restore snapshot**: Modal restores the filesystem from a saved image
2. **Quick sync**: Pulls latest changes (usually just a few commits)
3. **Start script**: Runs `.openinspect/start.sh` for runtime startup (if present)
3. **Start script**: Runs `scripts/.openinspect/start.sh` for runtime startup (if present)
4. **Ready**: Sandbox is ready almost instantly

Snapshots include installed dependencies, built artifacts, and workspace state. This is why
Expand All @@ -220,8 +221,8 @@ follow-up prompts in an existing session are much faster than the first prompt.
When starting from a pre-built repo image:

1. **Incremental git sync**: Fast fetch + hard reset to latest branch head
2. **Setup skipped**: `.openinspect/setup.sh` already ran when the image was built
3. **Start script runs**: `.openinspect/start.sh` executes for per-session runtime startup
2. **Setup skipped**: `scripts/.openinspect/setup.sh` already ran when the image was built
3. **Start script runs**: `scripts/.openinspect/start.sh` executes for per-session runtime startup
4. **Ready**: Agent starts once runtime hook succeeds

If `start.sh` exists and fails, startup fails fast instead of continuing with a broken runtime.
Expand Down Expand Up @@ -320,6 +321,26 @@ This lets you send follow-up thoughts while the agent works. Prompts are process

You can also stop the current execution if the agent is going down the wrong path.

### Plan-Mode Gate

When a session is in plan mode the message queue is not blocked — what changes is **how** each
prompt is dispatched. While `plan_mode = 1` and `plan_approval_status = "awaiting_approval"` (or
unset, pre-plan), every dispatched prompt runs as a planning turn (`planMode: true` in the command),
so a follow-up sent before you approve is treated as an amendment and produces plan v2 — not
blocked. Approve or reject flips `isPlanningTurn` to false; the next prompt then runs as a normal
build turn. The full workflow (triggers, approval UIs, amendments, plan vs build model split) lives
in [PLAN_MODE.md](PLAN_MODE.md).

### Prompt-Safety Wrapping

Bot-assembled prompts can contain untrusted text (a Linear issue body, a PR description, a Slack
thread). To stop prompt injection across the trusted/untrusted boundary, the bots wrap each piece of
user-supplied content in `<user_content>` blocks via `buildUntrustedUserContentBlock` from
`@open-inspect/shared` (HTML-escapes attributes, neutralizes literal `</user_content>` inside the
body). The sandbox bridge then wraps the whole prompt in `<user_message>` when a plan or resume
preamble is prepended, and also neutralizes literal `</user_message>` so a user can't close the
outer wrapper from inside.

---

## The Agent
Expand Down
178 changes: 178 additions & 0 deletions docs/PLAN_MODE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Plan mode

Get a plan before you get code.

Plan mode is a human-in-the-loop gate. The agent reads your request, proposes a markdown plan, and
stops. Nothing touches your branch until you approve. You can amend, reject, or accept — and you
pick which model implements the approved plan.

Use it when the task is non-trivial: refactor, redesign, multi-file change, architectural decision.
Skip it for typos, one-line fixes, or anything you can describe in a sentence.

## When plan mode kicks in

Plan mode is on by default for non-trivial requests, off for quick ones. The behavior is the same
across channels with one twist per channel.

| Channel | Default | How to force ON | How to force OFF |
| ---------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **Web** | The `Plan` toggle in the composer is OFF. | Click `Plan` before submitting. The model selector swaps to the deployment's plan model. | Leave the toggle OFF. |
| **Linear** | OFF unless labelled. | Add the `plan` label (or `plan-<alias>` to also override the plan model). | Remove the label. |
| **GitHub** | OFF unless labelled. | Same labels as Linear: `plan` or `plan-<alias>`. | Remove the label. |
| **Slack** | The bot decides from your message text. | Either enable `Plan first, then build` in App Home, or write a prompt the classifier recognizes as plan-worthy. | Phrase your message as a small, well-scoped change ("quick fix", "rename", "small enhancement"). |

The Slack classifier reads your `@mention` and picks plan-vs-build. A "refactor the auth module to
use the new pattern" triggers plan mode; a "fix the typo in the homepage hero" goes straight to
build. The App Home toggle, when on, forces plan mode on every session regardless of phrasing.

## What happens when plan mode is on

1. The agent runs a planning turn. It can read files but cannot edit, run shell, or open a PR.
2. It produces a markdown plan with: a one-sentence restatement of your goal, an ordered list of
concrete steps, and a short "Risks & open questions" section if anything is uncertain.
3. The session waits. The plan card carries a `Plan v1` header and an approval banner.
4. You **approve**, **reject**, or **amend** by sending a follow-up prompt.

The plan persists across turns. If you come back tomorrow, the next prompt re-anchors on the saved
plan instead of relying on conversational memory that may have been compacted.

## Approve, reject, amend

### Web

The approval banner sits above the composer with a `Build with` model picker and two buttons:
`Approve` and `Reject`. Approving dispatches an "Implement the approved plan vN" prompt with your
chosen model. Rejecting prompts for an optional reason and pauses the session. Clicking the
`Plan vN` pill scrolls to the top of the plan card.

### Linear

The bot pushes the plan to the issue as an elicitation activity. Reply in the same thread:

- `approve` — start the build (uses the model from a `model-<alias>` or `build-<alias>` label on the
issue, else the default)
- `reject` — discard the plan; optionally add a reason on the same line
- Anything else — the agent treats it as an amendment and proposes plan v2

Switch the build model by adding a label like `build-sonnet` or `model-opus` before approving.

### GitHub

Same commands as Linear (`approve`, `reject [reason]`), posted as a PR or issue comment. Labels for
model overrides: `plan-<alias>`, `build-<alias>`, `model-<alias>`, `review-<alias>`.

### Slack

The bot posts a `Plan v1 — awaiting your approval` block with the plan body and three buttons:
`Approve`, `Reject`, and `View plan in web`. Approve opens a modal to pick the build model. Reject
opens a modal for an optional reason. Both close cleanly when submitted.

## Models

Plan mode uses two models per session:

- **Plan model** — runs the planning turn. Defaults to the deployment-wide `defaultPlanModel`
(configurable in **Settings → Models → Default Models**).
- **Build model** — runs the implementation turn after approval. Defaults to `defaultModel`, but you
can switch it per session at approve time.

The split matters: planning benefits from a more capable model since the resulting plan steers the
implementation; the build model can be cheaper. The deployment defaults are stored in D1 and read by
every bot at session-creation time — no Terraform redeploy needed to change them.

### Label aliases (Linear + GitHub)

Linear forbids `:` in label names, so we use dash-separated everywhere:

| Label | Effect |
| ---------------- | ---------------------------------------------------------- |
| `plan` | Trigger plan mode (plan model = deployment default) |
| `plan-<alias>` | Trigger plan mode AND override the plan model |
| `model-<alias>` | Override the build model |
| `build-<alias>` | Same as `model-<alias>`, reads more naturally in plan mode |
| `review-<alias>` | Override the model used to auto-review a PR |

`<alias>` is the short name: `sonnet`, `opus`, `haiku`, `opus-4-7`, `gpt-5.4`, etc. The alias →
canonical model map lives in `@open-inspect/shared` so Linear and GitHub stay in sync.

## Settings → Models

Two dropdowns under **Default Models** read and write the deployment-wide defaults. Changes are
atomic; disabling a model that's the current default is blocked inline.

Bots (Linear, GitHub, Slack) call `GET /model-preferences` at session-creation time. Fallback chain:
`D1 > env var > shared constant`. If the control plane is unreachable, bots fall back to the shared
constant.

## Slack specifics

### App Home toggle

`Plan first, then build` — when ON, every session you start in Slack is gated by a plan. When OFF
(the default), the bot decides automatically from your prompt.

The toggle is per-user and stored in KV (`user_prefs:<slackUserId>`). Your toggle doesn't affect
anyone else.

### Smart detection

When the toggle is OFF, the repo classifier also decides plan-vs-build. It runs as part of the same
LLM call that picks the repo (or a dedicated lightweight call for the single-repo and channel-bound
fast paths). Decision rules:

- **Plan** — multi-step refactor / redesign / migration, feature spanning multiple files, "how
should we" questions, anything where reviewing the approach before code changes adds clear value.
- **Build** — bug fix with clear scope, typo / rename / small enhancement, questions, explicit "just
do X" or "quick fix", read-only investigations.

When uncertain, the classifier defaults to build to reduce friction. You can always re-prompt for a
plan.

## Architecture (high level)

```text
@mention / label / web prompt
┌──────────────────┐ planMode flag ┌─────────────────────────┐
│ control plane DO │ ───────────────────────▶│ sandbox bridge (Python) │
│ SessionService │ │ _handle_prompt │
│ plans table │◀── plan content ────────│ planning preamble + │
│ approval gate │ │ <user_message> wrap │
└──────────────────┘ └─────────────────────────┘
│ │
│ broadcasts: plan_status │ OpenCode (agent)
▼ ▼
Web / Linear / GitHub / Slack callbacks Markdown plan emitted
approve / reject / amend ──▶ next prompt runs as build
```

Key invariants:

- **Plan persistence** — plans live in the SessionDO SQLite `plans` table with monotonic versions
per session. v1 is `SUPERSEDED` once v2 lands. Approve/reject is terminal.
- **Dispatch gate** — while `plan_mode = 1` and `plan_approval_status = "awaiting_approval"`, every
prompt is dispatched as a planning turn (amendments produce plan v2, v3, …). Approve or reject
sets `isPlanningTurn` to false and subsequent prompts run as build turns.
- **Resume anchoring** — `_build_resume_preamble` injects the saved plan into the next prompt as
`<resume_context><saved_plan>…</saved_plan></resume_context>` so the agent re-anchors even after
context compaction.
- **Prompt safety** — bot-assembled content is wrapped in `<user_content>` blocks (HTML-escaped,
`</user_content>` neutralized). The sandbox bridge wraps that block in `<user_message>` and also
neutralizes literal `</user_message>` to prevent injection across the outer boundary.

## Endpoints (control plane)

| Method | Path | Purpose |
| ------------- | ---------------------------- | ------------------------------------------------------------------------------------ |
| `GET` | `/sessions/:id/plan` | Current plan + approval status |
| `POST` | `/sessions/:id/plan` | Save a new plan version (agent-source) |
| `GET` | `/sessions/:id/plans` | List all plan versions for a session |
| `POST` | `/sessions/:id/plan/approve` | Flip status to `approved`; optional `implementationModel` override |
| `POST` | `/sessions/:id/plan/reject` | Flip status to `rejected` with optional reason |
| `GET` / `PUT` | `/model-preferences` | Read/write deployment defaults (`defaultModel`, `defaultPlanModel`, `enabledModels`) |

The bots and web app proxy through their own API routes (`/api/sessions/[id]/plan/*`) for auth +
CSRF.
25 changes: 25 additions & 0 deletions packages/control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ The control plane provides:
| `/sessions/:id/archive` | POST | Archive session |
| `/sessions/:id/unarchive` | POST | Unarchive session |

### Plan Mode

When a session opts into plan mode the agent emits a markdown plan. While the plan is awaiting
approval, prompts continue to queue and are processed but are dispatched as **planning turns**
(re-anchored on the current plan) until the user approves, rejects, or amends. See
[docs/PLAN_MODE.md](../../docs/PLAN_MODE.md) for the workflow.

| Endpoint | Method | Description |
| ---------------------------- | ------ | ------------------------------------------------------------------ |
| `/sessions/:id/plan` | GET | Current plan + approval status |
| `/sessions/:id/plan` | POST | Save a new plan version (agent-source; bumps the version) |
| `/sessions/:id/plans` | GET | List all plan versions for a session |
| `/sessions/:id/plan/approve` | POST | Flip status to `approved`; optional `implementationModel` override |
| `/sessions/:id/plan/reject` | POST | Flip status to `rejected` with optional reason |

### Model Preferences

Deployment-wide model settings. Bots fetch these at session-creation time. Fallback chain when
unreachable: `D1 > env var > shared constant`.

| Endpoint | Method | Description |
| -------------------- | ------ | ---------------------------------------------------------------------------------------- |
| `/model-preferences` | GET | Returns `{ enabledModels, defaultModel, defaultPlanModel }` |
| `/model-preferences` | PUT | Atomic update of the three fields; rejects when `defaultModel` is not in `enabledModels` |

### Create PR Payload

`POST /sessions/:id/pr` accepts:
Expand Down
Loading